From 1ee85b567da759a0ab09905ea5b8768a13b8ba20 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 09:16:45 +0100 Subject: [PATCH 001/365] attempt to refactor nn graph building --- pygsp/graphs/nngraphs/nngraph.py | 117 ++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 34 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 397af4e7..95c2c5cc 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -10,6 +10,21 @@ _logger = utils.build_logger(__name__) +# conversion between the FLANN conventions and the various backend functions +_dist_translation = { + 'scipy-kdtree': { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf + }, + 'scipy-pdist' : { + 'euclidean': 'euclidean', + 'manhattan': 'cityblock', + 'max_dist': 'chebyshev', + 'minkowski': 'minkowski' + }, + + } def _import_pfl(): try: @@ -20,6 +35,46 @@ def _import_pfl(): 'pip (or conda) install pyflann (or pyflann3).') return pfl + + +def _knn_sp_kdtree(_X, _num_neighbors, _dist_type, _order=0): + kdt = spatial.KDTree(_X) + D, NN = kdt.query(_X, k=(_num_neighbors + 1), + p=_dist_translation['scipy-kdtree'][_dist_type]) + return NN, D + +def _knn_flann(_X, _num_neighbors, _dist_type, _order): + pfl = _import_pfl() + pfl.set_distance_type(_dist_type, order=_order) + flann = pfl.FLANN() + + # Default FLANN parameters (I tried changing the algorithm and + # testing performance on huge matrices, but the default one + # seems to work best). + NN, D = flann.nn(_X, _X, num_neighbors=(_num_neighbors + 1), + algorithm='kdtree') + return NN, D + +def _radius_sp_kdtree(_X, _epsilon, _dist_type, order=0): + kdt = spatial.KDTree(_X) + D, NN = kdt.query(_X, k=None, distance_upper_bound=_epsilon, + p=_dist_translation['scipy-kdtree'][_dist_type]) + return NN, D + +def _knn_sp_pdist(_X, _num_neighbors, _dist_type, _order): + pd = spatial.distance.squareform( + spatial.distance.pdist(_X, + _dist_translation['scipy-pdist'][_dist_type], + p=_order)) + pds = np.sort(pd)[:, 0:_num_neighbors+1] + pdi = pd.argsort()[:, 0:_num_neighbors+1] + return pdi, pds + +def _radius_sp_pdist(): + raise NotImplementedError() + +def _radius_flann(): + raise NotImplementedError() class NNGraph(Graph): r"""Nearest-neighbor graph from given point cloud. @@ -33,9 +88,11 @@ class NNGraph(Graph): Type of nearest neighbor graph to create. The options are 'knn' for k-Nearest Neighbors or 'radius' for epsilon-Nearest Neighbors (default is 'knn'). - use_flann : bool, optional - Use Fast Library for Approximate Nearest Neighbors (FLANN) or not. - (default is False) + backend : {'scipy-kdtree', 'scipy-pdist', 'flann'} + Type of the backend for graph construction. + - 'scipy-kdtree'(default) will use scipy.spatial.KDTree + - 'scipy-pdist' will use scipy.spatial.distance.pdist + - 'flann' use Fast Library for Approximate Nearest Neighbors (FLANN) center : bool, optional Center the data so that it has zero mean (default is True) rescale : bool, optional @@ -74,20 +131,34 @@ class NNGraph(Graph): """ - def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, + def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, rescale=True, k=10, sigma=0.1, epsilon=0.01, gtype=None, plotting={}, symmetrize_type='average', dist_type='euclidean', order=0, **kwargs): self.Xin = Xin self.NNtype = NNtype - self.use_flann = use_flann + self.backend = backend self.center = center self.rescale = rescale self.k = k self.sigma = sigma self.epsilon = epsilon - + _dist_translation['scipy-kdtree']['minkowski'] = order + + self._nn_functions = { + 'knn': { + 'scipy-kdtree':_knn_sp_kdtree, + 'scipy-pdist': _knn_sp_pdist, + 'flann': _knn_flann + }, + 'radius': { + 'scipy-kdtree':_radius_sp_kdtree, + 'scipy-pdist': _radius_sp_pdist, + 'flann': _radius_flann + }, + } + if gtype is None: gtype = 'nearest neighbors' else: @@ -108,33 +179,15 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, scale = np.power(N, 1. / float(min(d, 3))) / 10. Xout *= scale / bounding_radius - # Translate distance type string to corresponding Minkowski order. - dist_translation = {"euclidean": 2, - "manhattan": 1, - "max_dist": np.inf, - "minkowski": order - } + if self.NNtype == 'knn': spi = np.zeros((N * k)) spj = np.zeros((N * k)) spv = np.zeros((N * k)) - if self.use_flann: - pfl = _import_pfl() - pfl.set_distance_type(dist_type, order=order) - flann = pfl.FLANN() - - # Default FLANN parameters (I tried changing the algorithm and - # testing performance on huge matrices, but the default one - # seems to work best). - NN, D = flann.nn(Xout, Xout, num_neighbors=(k + 1), - algorithm='kdtree') - - else: - kdt = spatial.KDTree(Xout) - D, NN = kdt.query(Xout, k=(k + 1), - p=dist_translation[dist_type]) + NN, D = self._nn_functions[NNtype][backend](Xout, k, + dist_type, order) for i in range(N): spi[i * k:(i + 1) * k] = np.kron(np.ones((k)), i) @@ -144,13 +197,9 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, elif self.NNtype == 'radius': - kdt = spatial.KDTree(Xout) - D, NN = kdt.query(Xout, k=None, distance_upper_bound=epsilon, - p=dist_translation[dist_type]) - count = 0 - for i in range(N): - count = count + len(NN[i]) - + NN, D = self.__nn_functions[NNtype][backend](Xout, epsilon, dist_type, order) + count = sum(map(len, NN)) + spi = np.zeros((count)) spj = np.zeros((count)) spv = np.zeros((count)) From 4bacd5c70579c68647daf794bcb2078281a528f0 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 11:14:21 +0100 Subject: [PATCH 002/365] update tests --- pygsp/graphs/nngraphs/nngraph.py | 7 ++++--- pygsp/tests/test_graphs.py | 29 ++++++++++++++++------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 95c2c5cc..4b9f2f3d 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -91,7 +91,7 @@ class NNGraph(Graph): backend : {'scipy-kdtree', 'scipy-pdist', 'flann'} Type of the backend for graph construction. - 'scipy-kdtree'(default) will use scipy.spatial.KDTree - - 'scipy-pdist' will use scipy.spatial.distance.pdist + - 'scipy-pdist' will use scipy.spatial.distance.pdist (slowest but exact) - 'flann' use Fast Library for Approximate Nearest Neighbors (FLANN) center : bool, optional Center the data so that it has zero mean (default is True) @@ -187,7 +187,7 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, spv = np.zeros((N * k)) NN, D = self._nn_functions[NNtype][backend](Xout, k, - dist_type, order) + dist_type, order) for i in range(N): spi[i * k:(i + 1) * k] = np.kron(np.ones((k)), i) @@ -197,7 +197,8 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, elif self.NNtype == 'radius': - NN, D = self.__nn_functions[NNtype][backend](Xout, epsilon, dist_type, order) + NN, D = self.__nn_functions[NNtype][backend](Xout, epsilon, + dist_type, order) count = sum(map(len, NN)) spi = np.zeros((count)) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index dc51fec5..afde1947 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -182,19 +182,22 @@ def test_set_coordinates(self): def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - - for dist_type in dist_types: - - # Only p-norms with 1<=p<=infinity permitted. - if dist_type != 'minkowski': - graphs.NNGraph(Xin, NNtype='radius', dist_type=dist_type) - graphs.NNGraph(Xin, NNtype='knn', dist_type=dist_type) - - # Distance type unsupported in the C bindings, - # use the C++ bindings instead. - if dist_type != 'max_dist': - graphs.NNGraph(Xin, use_flann=True, NNtype='knn', - dist_type=dist_type) + backends = ['scipy-kdtree', 'scipy-pdist', 'flann'] + for cur_backend in backends: + for dist_type in dist_types: + + # Only p-norms with 1<=p<=infinity permitted. + if dist_type != 'minkowski': + graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, + dist_type=dist_type) + graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, + dist_type=dist_type) + + # Distance type unsupported in the C bindings, + # use the C++ bindings instead. + if dist_type != 'max_dist': + graphs.NNGraph(Xin, backend=cur_backend, NNtype='knn', + dist_type=dist_type) def test_bunny(self): graphs.Bunny() From 00bbcdd37579cae13a24e620df6e0bec303f1883 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 11:42:11 +0100 Subject: [PATCH 003/365] fix typo --- pygsp/graphs/nngraphs/nngraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 4b9f2f3d..f3fc9908 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -197,7 +197,7 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, elif self.NNtype == 'radius': - NN, D = self.__nn_functions[NNtype][backend](Xout, epsilon, + NN, D = self._nn_functions[NNtype][backend](Xout, epsilon, dist_type, order) count = sum(map(len, NN)) From b822333ac85adbbc69b85453732ce5073efbf1be Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 12:30:45 +0100 Subject: [PATCH 004/365] fix tests (avoiding not implemented combinations) --- pygsp/graphs/nngraphs/nngraph.py | 4 ++-- pygsp/tests/test_graphs.py | 33 ++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index f3fc9908..d7b112fd 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -70,10 +70,10 @@ def _knn_sp_pdist(_X, _num_neighbors, _dist_type, _order): pdi = pd.argsort()[:, 0:_num_neighbors+1] return pdi, pds -def _radius_sp_pdist(): +def _radius_sp_pdist(_X, _epsilon, _dist_type, order=0): raise NotImplementedError() -def _radius_flann(): +def _radius_flann(_X, _epsilon, _dist_type, order=0): raise NotImplementedError() class NNGraph(Graph): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index afde1947..cdbad6ce 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -183,21 +183,30 @@ def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'scipy-pdist', 'flann'] + order=3 # for minkowski + for cur_backend in backends: - for dist_type in dist_types: - - # Only p-norms with 1<=p<=infinity permitted. + for dist_type in dist_types: if dist_type != 'minkowski': - graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, - dist_type=dist_type) - graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, - dist_type=dist_type) - - # Distance type unsupported in the C bindings, - # use the C++ bindings instead. - if dist_type != 'max_dist': - graphs.NNGraph(Xin, backend=cur_backend, NNtype='knn', + # curently radius only implemented with scipy kdtree + if cur_backend == 'scipy-kdtree': + graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, + dist_type=dist_type) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, dist_type=dist_type) + else: + # Only p-norms with 1<=p<=infinity permitted. + # flann only accepts integer orders + if cur_backend == 'scipy-kdtree': + graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, + dist_type=dist_type, order=order) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, + dist_type=dist_type, order=order) + def test_bunny(self): graphs.Bunny() From 38aebd0f77af6a5cf1ed733fb80e31d95b111434 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 13:55:46 +0100 Subject: [PATCH 005/365] - fix missing space after colon in dictionary - do not use underscores in functions args --- pygsp/graphs/nngraphs/nngraph.py | 36 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index d7b112fd..2d0fce2d 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -37,37 +37,37 @@ def _import_pfl(): -def _knn_sp_kdtree(_X, _num_neighbors, _dist_type, _order=0): - kdt = spatial.KDTree(_X) - D, NN = kdt.query(_X, k=(_num_neighbors + 1), - p=_dist_translation['scipy-kdtree'][_dist_type]) +def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): + kdt = spatial.KDTree(X) + D, NN = kdt.query(X, k=(num_neighbors + 1), + p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D -def _knn_flann(_X, _num_neighbors, _dist_type, _order): +def _knn_flann(X, num_neighbors, dist_type, order): pfl = _import_pfl() - pfl.set_distance_type(_dist_type, order=_order) + pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() # Default FLANN parameters (I tried changing the algorithm and # testing performance on huge matrices, but the default one # seems to work best). - NN, D = flann.nn(_X, _X, num_neighbors=(_num_neighbors + 1), + NN, D = flann.nn(X, X, num_neighbors=(num_neighbors + 1), algorithm='kdtree') return NN, D -def _radius_sp_kdtree(_X, _epsilon, _dist_type, order=0): - kdt = spatial.KDTree(_X) - D, NN = kdt.query(_X, k=None, distance_upper_bound=_epsilon, - p=_dist_translation['scipy-kdtree'][_dist_type]) +def _radius_sp_kdtree(X, epsilon, dist_type, order=0): + kdt = spatial.KDTree(X) + D, NN = kdt.query(X, k=None, distance_upper_bound=epsilon, + p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D -def _knn_sp_pdist(_X, _num_neighbors, _dist_type, _order): +def _knn_sp_pdist(X, num_neighbors, dist_type, _order): pd = spatial.distance.squareform( - spatial.distance.pdist(_X, - _dist_translation['scipy-pdist'][_dist_type], + spatial.distance.pdist(X, + _dist_translation['scipy-pdist'][dist_type], p=_order)) - pds = np.sort(pd)[:, 0:_num_neighbors+1] - pdi = pd.argsort()[:, 0:_num_neighbors+1] + pds = np.sort(pd)[:, 0:num_neighbors+1] + pdi = pd.argsort()[:, 0:num_neighbors+1] return pdi, pds def _radius_sp_pdist(_X, _epsilon, _dist_type, order=0): @@ -148,12 +148,12 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, self._nn_functions = { 'knn': { - 'scipy-kdtree':_knn_sp_kdtree, + 'scipy-kdtree': _knn_sp_kdtree, 'scipy-pdist': _knn_sp_pdist, 'flann': _knn_flann }, 'radius': { - 'scipy-kdtree':_radius_sp_kdtree, + 'scipy-kdtree': _radius_sp_kdtree, 'scipy-pdist': _radius_sp_pdist, 'flann': _radius_flann }, From 524c60fcde3a68b034dcdeaa85cef30256639059 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 14:09:03 +0100 Subject: [PATCH 006/365] fix (matlab) GSP url --- README.rst | 2 +- pygsp/tests/test_graphs.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 2d6c59d4..99d27364 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,7 @@ The documentation is available on `Read the Docs `_ and development takes place on `GitHub `_. -(A `Matlab counterpart `_ exists.) +(A `Matlab counterpart `_ exists.) The PyGSP facilitates a wide variety of operations on graphs, like computing their Fourier basis, filtering or interpolating signals, plotting graphs, diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index cdbad6ce..fd66deda 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -186,7 +186,8 @@ def test_nngraph(self): order=3 # for minkowski for cur_backend in backends: - for dist_type in dist_types: + for dist_type in dist_types: + print("backend={} dist={}".format(cur_backend, dist_type)) if dist_type != 'minkowski': # curently radius only implemented with scipy kdtree if cur_backend == 'scipy-kdtree': From ae838148d584f8ae66baa0deb8fa764230d051de Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 15:05:45 +0100 Subject: [PATCH 007/365] throw exception when using FLANN + max_dist (produces incorrect results) --- pygsp/graphs/nngraphs/nngraph.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 2d0fce2d..827b2d1d 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -45,6 +45,10 @@ def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): def _knn_flann(X, num_neighbors, dist_type, order): pfl = _import_pfl() + # the combination FLANN + max_dist produces incorrect results + # do not allow it + if dist_type == 'max_dist': + raise ValueError('FLANN and max_dist is not supported') pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() From 62fc0ce26ddc46405fe229253559eb05a1838ac4 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 19 Mar 2018 15:21:43 +0100 Subject: [PATCH 008/365] update test case to fit FLANN & max_dist exception --- pygsp/tests/test_graphs.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index fd66deda..484f32ae 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -182,24 +182,16 @@ def test_set_coordinates(self): def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-pdist', 'flann'] - order=3 # for minkowski + backends = ['scipy-kdtree', 'scipy-pdist', 'flann'] + order=3 # for minkowski, FLANN only accepts integer orders for cur_backend in backends: for dist_type in dist_types: - print("backend={} dist={}".format(cur_backend, dist_type)) - if dist_type != 'minkowski': - # curently radius only implemented with scipy kdtree - if cur_backend == 'scipy-kdtree': - graphs.NNGraph(Xin, NNtype='radius', - backend=cur_backend, - dist_type=dist_type) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type) + if cur_backend == 'flann' and dist_type == 'max_dist': + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='knn', backend=cur_backend, + dist_type=dist_type) else: - # Only p-norms with 1<=p<=infinity permitted. - # flann only accepts integer orders if cur_backend == 'scipy-kdtree': graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, @@ -208,7 +200,6 @@ def test_nngraph(self): backend=cur_backend, dist_type=dist_type, order=order) - def test_bunny(self): graphs.Bunny() From 6f473fa4175e68d5eba4d1640559ea2fb55c9b13 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 07:44:42 +0100 Subject: [PATCH 009/365] implement nn graph using pdist using radius --- pygsp/graphs/nngraphs/nngraph.py | 35 ++++++++++++++++++++++++++------ pygsp/tests/test_graphs.py | 2 +- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 827b2d1d..d32f2cc0 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -65,20 +65,43 @@ def _radius_sp_kdtree(X, epsilon, dist_type, order=0): p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D -def _knn_sp_pdist(X, num_neighbors, dist_type, _order): +def _knn_sp_pdist(X, num_neighbors, dist_type, order): pd = spatial.distance.squareform( spatial.distance.pdist(X, _dist_translation['scipy-pdist'][dist_type], - p=_order)) + p=order)) pds = np.sort(pd)[:, 0:num_neighbors+1] pdi = pd.argsort()[:, 0:num_neighbors+1] return pdi, pds -def _radius_sp_pdist(_X, _epsilon, _dist_type, order=0): - raise NotImplementedError() +def _radius_sp_pdist(X, epsilon, dist_type, order): + N, dim = np.shape(X) + pd = spatial.distance.squareform( + spatial.distance.pdist(X, + _dist_translation['scipy-pdist'][dist_type], + p=order)) + pdf = pd < epsilon + D = [] + NN = [] + for k in range(N): + v = pd[k, pdf[k, :]] + # use the same conventions as in scipy.distance.kdtree + NN.append(v.argsort()) + D.append(np.sort(v)) + + return NN, D -def _radius_flann(_X, _epsilon, _dist_type, order=0): - raise NotImplementedError() +def _radius_flann(X, epsilon, dist_type, order=0): + pfl = _import_pfl() + # the combination FLANN + max_dist produces incorrect results + # do not allow it + if dist_type == 'max_dist': + raise ValueError('FLANN and max_dist is not supported') + pfl.set_distance_type(dist_type, order=order) + flann = pfl.FLANN() + flann.build_index(X) + + flann.delete_index() class NNGraph(Graph): r"""Nearest-neighbor graph from given point cloud. diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 484f32ae..bdeaf9d1 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -192,7 +192,7 @@ def test_nngraph(self): NNtype='knn', backend=cur_backend, dist_type=dist_type) else: - if cur_backend == 'scipy-kdtree': + if cur_backend != 'flann': graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, dist_type=dist_type, order=order) From 25ec6d23f20875c9f148d650a5ce4d37641d7aa8 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 09:09:02 +0100 Subject: [PATCH 010/365] implement radius nn graph with flann --- pygsp/graphs/nngraphs/nngraph.py | 36 ++++++++++++++++++++------------ pygsp/tests/test_graphs.py | 7 +++---- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index d32f2cc0..d65a92b9 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -3,7 +3,8 @@ import traceback import numpy as np -from scipy import sparse, spatial +from scipy import sparse +import scipy.spatial as sps from pygsp import utils from pygsp.graphs import Graph # prevent circular import in Python < 3.5 @@ -38,17 +39,17 @@ def _import_pfl(): def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): - kdt = spatial.KDTree(X) + kdt = sps.KDTree(X) D, NN = kdt.query(X, k=(num_neighbors + 1), p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D def _knn_flann(X, num_neighbors, dist_type, order): - pfl = _import_pfl() # the combination FLANN + max_dist produces incorrect results # do not allow it if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') + pfl = _import_pfl() pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() @@ -60,26 +61,26 @@ def _knn_flann(X, num_neighbors, dist_type, order): return NN, D def _radius_sp_kdtree(X, epsilon, dist_type, order=0): - kdt = spatial.KDTree(X) + kdt = sps.KDTree(X) D, NN = kdt.query(X, k=None, distance_upper_bound=epsilon, p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D def _knn_sp_pdist(X, num_neighbors, dist_type, order): - pd = spatial.distance.squareform( - spatial.distance.pdist(X, - _dist_translation['scipy-pdist'][dist_type], - p=order)) + pd = sps.distance.squareform( + sps.distance.pdist(X, + metric=_dist_translation['scipy-pdist'][dist_type], + p=order)) pds = np.sort(pd)[:, 0:num_neighbors+1] pdi = pd.argsort()[:, 0:num_neighbors+1] return pdi, pds def _radius_sp_pdist(X, epsilon, dist_type, order): N, dim = np.shape(X) - pd = spatial.distance.squareform( - spatial.distance.pdist(X, - _dist_translation['scipy-pdist'][dist_type], - p=order)) + pd = sps.distance.squareform( + sps.distance.pdist(X, + metric=_dist_translation['scipy-pdist'][dist_type], + p=order)) pdf = pd < epsilon D = [] NN = [] @@ -92,16 +93,25 @@ def _radius_sp_pdist(X, epsilon, dist_type, order): return NN, D def _radius_flann(X, epsilon, dist_type, order=0): - pfl = _import_pfl() + N, dim = np.shape(X) # the combination FLANN + max_dist produces incorrect results # do not allow it if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') + + pfl = _import_pfl() pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() flann.build_index(X) + D = [] + NN = [] + for k in range(N): + nn, d = flann.nn_radius(X[k, :], epsilon) + D.append(d) + NN.append(nn) flann.delete_index() + return NN, D class NNGraph(Graph): r"""Nearest-neighbor graph from given point cloud. diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index bdeaf9d1..6e70347c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -192,10 +192,9 @@ def test_nngraph(self): NNtype='knn', backend=cur_backend, dist_type=dist_type) else: - if cur_backend != 'flann': - graphs.NNGraph(Xin, NNtype='radius', - backend=cur_backend, - dist_type=dist_type, order=order) + graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, + dist_type=dist_type, order=order) graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type, order=order) From 96b628e2bf86ed31c2c753d73d80d3d5e10243aa Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 10:06:03 +0100 Subject: [PATCH 011/365] flann returns the squared distance when called with 'euclidean' distance -> fix fix radius nn graph for spdist --- pygsp/graphs/nngraphs/nngraph.py | 29 ++++++++++++++++++++--------- pygsp/tests/test_graphs.py | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index d65a92b9..175460f0 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -49,6 +49,7 @@ def _knn_flann(X, num_neighbors, dist_type, order): # do not allow it if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') + pfl = _import_pfl() pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() @@ -58,6 +59,8 @@ def _knn_flann(X, num_neighbors, dist_type, order): # seems to work best). NN, D = flann.nn(X, X, num_neighbors=(num_neighbors + 1), algorithm='kdtree') + if dist_type == 'euclidean': # flann returns squared distances + return NN, np.sqrt(D) return NN, D def _radius_sp_kdtree(X, epsilon, dist_type, order=0): @@ -86,8 +89,9 @@ def _radius_sp_pdist(X, epsilon, dist_type, order): NN = [] for k in range(N): v = pd[k, pdf[k, :]] + d = pd[k, :].argsort() # use the same conventions as in scipy.distance.kdtree - NN.append(v.argsort()) + NN.append(d[0:len(v)]) D.append(np.sort(v)) return NN, D @@ -98,8 +102,8 @@ def _radius_flann(X, epsilon, dist_type, order=0): # do not allow it if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') - pfl = _import_pfl() + pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() flann.build_index(X) @@ -107,12 +111,23 @@ def _radius_flann(X, epsilon, dist_type, order=0): D = [] NN = [] for k in range(N): - nn, d = flann.nn_radius(X[k, :], epsilon) + nn, d = flann.nn_radius(X[k, :], epsilon*epsilon) D.append(d) NN.append(nn) flann.delete_index() + if dist_type == 'euclidean': # flann returns squared distances + return NN, np.sqrt(D) return NN, D +def center_input(X, N): + return X - np.kron(np.ones((N, 1)), np.mean(X, axis=0)) + +def rescale_input(X, N, d): + bounding_radius = 0.5 * np.linalg.norm(np.amax(X, axis=0) - + np.amin(X, axis=0), 2) + scale = np.power(N, 1. / float(min(d, 3))) / 10. + return X * scale / bounding_radius + class NNGraph(Graph): r"""Nearest-neighbor graph from given point cloud. @@ -207,14 +222,10 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, Xout = self.Xin if self.center: - Xout = self.Xin - np.kron(np.ones((N, 1)), - np.mean(self.Xin, axis=0)) + Xout = center_input(Xout, N) if self.rescale: - bounding_radius = 0.5 * np.linalg.norm(np.amax(Xout, axis=0) - - np.amin(Xout, axis=0), 2) - scale = np.power(N, 1. / float(min(d, 3))) / 10. - Xout *= scale / bounding_radius + Xout = rescale_input(Xout, N, d) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6e70347c..c7853839 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -187,6 +187,7 @@ def test_nngraph(self): for cur_backend in backends: for dist_type in dist_types: + #print("backend={} dist={}".format(cur_backend, dist_type)) if cur_backend == 'flann' and dist_type == 'max_dist': self.assertRaises(ValueError, graphs.NNGraph, Xin, NNtype='knn', backend=cur_backend, @@ -199,6 +200,20 @@ def test_nngraph(self): backend=cur_backend, dist_type=dist_type, order=order) + def test_nngraph_consistency(self): + #Xin = np.arange(180).reshape(60, 3) + Xin = np.random.uniform(-5, 5, (60, 3)) + dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + backends = ['scipy-kdtree', 'flann'] + num_neighbors=5 + + G = graphs.NNGraph(Xin, NNtype='knn', + backend='scipy-pdist', k=num_neighbors) + for cur_backend in backends: + for dist_type in dist_types: + Gt = graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, k=num_neighbors) + def test_bunny(self): graphs.Bunny() From 09bbff42c295829d14e05e7dce07db5df33d6d81 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 15:33:04 +0100 Subject: [PATCH 012/365] compute sqrt of list properly --- pygsp/graphs/nngraphs/nngraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 175460f0..085b7efc 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -116,7 +116,7 @@ def _radius_flann(X, epsilon, dist_type, order=0): NN.append(nn) flann.delete_index() if dist_type == 'euclidean': # flann returns squared distances - return NN, np.sqrt(D) + return NN, list(map(np.sqrt, D)) return NN, D def center_input(X, N): From 27b9a038d16f129fb63a1e3344fe2fb81e8936bf Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 15:14:08 +0100 Subject: [PATCH 013/365] use cyflann instead of pyflann (radius search not working) --- pygsp/graphs/nngraphs/nngraph.py | 43 +++++++++++++++++--------------- setup.py | 4 +-- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 085b7efc..979319e4 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -27,14 +27,14 @@ } -def _import_pfl(): +def _import_cfl(): try: - import pyflann as pfl + import cyflann as cfl except Exception: - raise ImportError('Cannot import pyflann. Choose another nearest ' + raise ImportError('Cannot import cyflann. Choose another nearest ' 'neighbors method or try to install it with ' - 'pip (or conda) install pyflann (or pyflann3).') - return pfl + 'pip (or conda) install cyflann.') + return cfl @@ -50,15 +50,15 @@ def _knn_flann(X, num_neighbors, dist_type, order): if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') - pfl = _import_pfl() - pfl.set_distance_type(dist_type, order=order) - flann = pfl.FLANN() - + cfl = _import_cfl() + cfl.set_distance_type(dist_type, order=order) + c = cfl.FLANNIndex(algorithm='kdtree') + c.build_index(X) # Default FLANN parameters (I tried changing the algorithm and # testing performance on huge matrices, but the default one # seems to work best). - NN, D = flann.nn(X, X, num_neighbors=(num_neighbors + 1), - algorithm='kdtree') + NN, D = c.nn_index(X, num_neighbors + 1) + c.free_index() if dist_type == 'euclidean': # flann returns squared distances return NN, np.sqrt(D) return NN, D @@ -102,19 +102,18 @@ def _radius_flann(X, epsilon, dist_type, order=0): # do not allow it if dist_type == 'max_dist': raise ValueError('FLANN and max_dist is not supported') - pfl = _import_pfl() - - pfl.set_distance_type(dist_type, order=order) - flann = pfl.FLANN() - flann.build_index(X) + cfl = _import_cfl() + cfl.set_distance_type(dist_type, order=order) + c = cfl.FLANNIndex(algorithm='kdtree') + c.build_index(X) D = [] NN = [] for k in range(N): - nn, d = flann.nn_radius(X[k, :], epsilon*epsilon) + nn, d = c.nn_radius(X[k, :], epsilon*epsilon) D.append(d) NN.append(nn) - flann.delete_index() + c.free_index() if dist_type == 'euclidean': # flann returns squared distances return NN, list(map(np.sqrt, D)) return NN, D @@ -228,7 +227,13 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, Xout = rescale_input(Xout, N, d) + if self._nn_functions.get(NNtype) == None: + raise ValueError('Invalid NNtype {}'.format(self.NNtype)) + if self._nn_functions[NNtype].get(backend) == None: + raise ValueError('Invalid backend {} for type {}'.format(backend, + self.NNtype)) + if self.NNtype == 'knn': spi = np.zeros((N * k)) spj = np.zeros((N * k)) @@ -262,8 +267,6 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, float(self.sigma)) start = start + leng - else: - raise ValueError('Unknown NNtype {}'.format(self.NNtype)) W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) diff --git a/setup.py b/setup.py index bcf0ccd9..b247f3a2 100644 --- a/setup.py +++ b/setup.py @@ -30,8 +30,8 @@ # Construct patch graphs from images. 'scikit-image', # Approximate nearest neighbors for kNN graphs. - 'pyflann; python_version == "2.*"', - 'pyflann3; python_version == "3.*"', + 'flann', + 'cyflann', # Convex optimization on graph. 'pyunlocbox', # Plot graphs, signals, and filters. From 8a1f9b907b45a4cbad2b8ba48cddc535f7f82981 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 15:14:39 +0100 Subject: [PATCH 014/365] check nn graphs building against pdist reference --- pygsp/tests/test_graphs.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c7853839..48277e3f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -9,6 +9,7 @@ import numpy as np import scipy.linalg +import scipy.sparse.linalg from skimage import data, img_as_float from pygsp import graphs @@ -199,20 +200,45 @@ def test_nngraph(self): graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type, order=order) + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='badtype', backend=cur_backend, + dist_type=dist_type) + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='knn', backend='badtype', + dist_type=dist_type) def test_nngraph_consistency(self): - #Xin = np.arange(180).reshape(60, 3) Xin = np.random.uniform(-5, 5, (60, 3)) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'flann'] - num_neighbors=5 + num_neighbors=4 + epsilon=0.1 + # use pdist as ground truth G = graphs.NNGraph(Xin, NNtype='knn', backend='scipy-pdist', k=num_neighbors) - for cur_backend in backends: + for cur_backend in backends: for dist_type in dist_types: + if cur_backend == 'flann' and dist_type == 'max_dist': + continue + #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, k=num_neighbors) + d = scipy.sparse.linalg.norm(G.W - Gt.W) + self.assertTrue(d < 0.01, 'Graphs (knn) are not identical error='.format(d)) + + G = graphs.NNGraph(Xin, NNtype='radius', + backend='scipy-pdist', epsilon=epsilon) + for cur_backend in backends: + for dist_type in dist_types: + if cur_backend == 'flann' and dist_type == 'max_dist': + continue + #print("backend={} dist={}".format(cur_backend, dist_type)) + Gt = graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, epsilon=epsilon) + d = scipy.sparse.linalg.norm(G.W - Gt.W, ord=1) + self.assertTrue(d < 0.01, + 'Graphs (radius) are not identical error='.format(d)) def test_bunny(self): graphs.Bunny() From 6e9e2ac856a2fcafa36c2fc2fd12b8b6f1055fa2 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 15:36:49 +0100 Subject: [PATCH 015/365] cyflann needs the flann library to be installed on the system try to install via before_install --- .travis.yml | 4 ++++ setup.py | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 835c6d85..d8f45358 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,10 @@ python: - 3.5 - 3.6 +before_install: + - sudo apt-get -qq update + - sudo apt-get install -y libflann-dev + addons: apt: packages: diff --git a/setup.py b/setup.py index b247f3a2..a9f92004 100644 --- a/setup.py +++ b/setup.py @@ -30,8 +30,7 @@ # Construct patch graphs from images. 'scikit-image', # Approximate nearest neighbors for kNN graphs. - 'flann', - 'cyflann', + 'cyflann', # Convex optimization on graph. 'pyunlocbox', # Plot graphs, signals, and filters. @@ -60,7 +59,7 @@ # Dependencies to build and upload packages. 'pkg': [ 'wheel', - 'twine', + 'twine' ], }, license="BSD", From 811de06e5adc694e8ba496bbc59e7d874b9b9695 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 15:14:39 +0100 Subject: [PATCH 016/365] check nn graphs building against pdist reference --- pygsp/tests/test_graphs.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c7853839..48277e3f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -9,6 +9,7 @@ import numpy as np import scipy.linalg +import scipy.sparse.linalg from skimage import data, img_as_float from pygsp import graphs @@ -199,20 +200,45 @@ def test_nngraph(self): graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type, order=order) + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='badtype', backend=cur_backend, + dist_type=dist_type) + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='knn', backend='badtype', + dist_type=dist_type) def test_nngraph_consistency(self): - #Xin = np.arange(180).reshape(60, 3) Xin = np.random.uniform(-5, 5, (60, 3)) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'flann'] - num_neighbors=5 + num_neighbors=4 + epsilon=0.1 + # use pdist as ground truth G = graphs.NNGraph(Xin, NNtype='knn', backend='scipy-pdist', k=num_neighbors) - for cur_backend in backends: + for cur_backend in backends: for dist_type in dist_types: + if cur_backend == 'flann' and dist_type == 'max_dist': + continue + #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, k=num_neighbors) + d = scipy.sparse.linalg.norm(G.W - Gt.W) + self.assertTrue(d < 0.01, 'Graphs (knn) are not identical error='.format(d)) + + G = graphs.NNGraph(Xin, NNtype='radius', + backend='scipy-pdist', epsilon=epsilon) + for cur_backend in backends: + for dist_type in dist_types: + if cur_backend == 'flann' and dist_type == 'max_dist': + continue + #print("backend={} dist={}".format(cur_backend, dist_type)) + Gt = graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, epsilon=epsilon) + d = scipy.sparse.linalg.norm(G.W - Gt.W, ord=1) + self.assertTrue(d < 0.01, + 'Graphs (radius) are not identical error='.format(d)) def test_bunny(self): graphs.Bunny() From 813fe3988c89d693e685d61ce5d69fbd8cd6070a Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 16:18:33 +0100 Subject: [PATCH 017/365] backport stuff from cyflann branch --- pygsp/graphs/nngraphs/nngraph.py | 7 +++++-- pygsp/tests/test_graphs.py | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 085b7efc..747c8542 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -228,7 +228,12 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, Xout = rescale_input(Xout, N, d) + if self._nn_functions.get(NNtype) == None: + raise ValueError('Invalid NNtype {}'.format(self.NNtype)) + if self._nn_functions[NNtype].get(backend) == None: + raise ValueError('Invalid backend {} for type {}'.format(backend, + self.NNtype)) if self.NNtype == 'knn': spi = np.zeros((N * k)) spj = np.zeros((N * k)) @@ -262,8 +267,6 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, float(self.sigma)) start = start + leng - else: - raise ValueError('Unknown NNtype {}'.format(self.NNtype)) W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 48277e3f..b2ddf18b 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -219,26 +219,26 @@ def test_nngraph_consistency(self): backend='scipy-pdist', k=num_neighbors) for cur_backend in backends: for dist_type in dist_types: - if cur_backend == 'flann' and dist_type == 'max_dist': + if cur_backend == 'flann': # skip flann for now continue #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, k=num_neighbors) d = scipy.sparse.linalg.norm(G.W - Gt.W) - self.assertTrue(d < 0.01, 'Graphs (knn) are not identical error='.format(d)) + self.assertTrue(d < 0.01, "Graphs (knn) are not identical") G = graphs.NNGraph(Xin, NNtype='radius', backend='scipy-pdist', epsilon=epsilon) for cur_backend in backends: for dist_type in dist_types: - if cur_backend == 'flann' and dist_type == 'max_dist': + if cur_backend == 'flann': # skip flann for now continue #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, epsilon=epsilon) d = scipy.sparse.linalg.norm(G.W - Gt.W, ord=1) self.assertTrue(d < 0.01, - 'Graphs (radius) are not identical error='.format(d)) + "Graphs (radius) are not identical") def test_bunny(self): graphs.Bunny() From 4a4d59796aa1c8c3f0a040122bf8659119690fa2 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Tue, 20 Mar 2018 16:24:33 +0100 Subject: [PATCH 018/365] flann should (mostly) work for knn graphs --- pygsp/tests/test_graphs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b2ddf18b..8d1a008d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -219,7 +219,7 @@ def test_nngraph_consistency(self): backend='scipy-pdist', k=num_neighbors) for cur_backend in backends: for dist_type in dist_types: - if cur_backend == 'flann': # skip flann for now + if cur_backend == 'flann' and dist_type == 'max_dist': continue #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='knn', From 53dffc12670c6664fddca317c76a8a2199e1e992 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Wed, 21 Mar 2018 08:18:49 +0100 Subject: [PATCH 019/365] fix pdist warnings --- pygsp/graphs/nngraphs/nngraph.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 979319e4..c2357468 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -5,6 +5,7 @@ import numpy as np from scipy import sparse import scipy.spatial as sps +import scipy.spatial.distance as spsd from pygsp import utils from pygsp.graphs import Graph # prevent circular import in Python < 3.5 @@ -70,20 +71,24 @@ def _radius_sp_kdtree(X, epsilon, dist_type, order=0): return NN, D def _knn_sp_pdist(X, num_neighbors, dist_type, order): - pd = sps.distance.squareform( - sps.distance.pdist(X, - metric=_dist_translation['scipy-pdist'][dist_type], - p=order)) + if dist_type == 'minkowski': + p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type], + p=order) + else: + p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type]) + pd = spsd.squareform(p) pds = np.sort(pd)[:, 0:num_neighbors+1] pdi = pd.argsort()[:, 0:num_neighbors+1] return pdi, pds def _radius_sp_pdist(X, epsilon, dist_type, order): N, dim = np.shape(X) - pd = sps.distance.squareform( - sps.distance.pdist(X, - metric=_dist_translation['scipy-pdist'][dist_type], - p=order)) + if dist_type == 'minkowski': + p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type], + p=order) + else: + p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type]) + pd = spsd.squareform(p) pdf = pd < epsilon D = [] NN = [] From 1309e92b963f5177822fb1e5406b2bc67f812272 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Wed, 21 Mar 2018 12:28:35 +0100 Subject: [PATCH 020/365] implement and use scipy-ckdtree as default (faster than kdtree) --- pygsp/graphs/nngraphs/nngraph.py | 40 +++++++++++++++++++++++++++++--- pygsp/tests/test_graphs.py | 13 ++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 747c8542..faa4d9f5 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -18,6 +18,11 @@ 'manhattan': 1, 'max_dist': np.inf }, + 'scipy-ckdtree': { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf + }, 'scipy-pdist' : { 'euclidean': 'euclidean', 'manhattan': 'cityblock', @@ -44,6 +49,13 @@ def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D +def _knn_sp_ckdtree(X, num_neighbors, dist_type, order=0): + kdt = sps.cKDTree(X) + D, NN = kdt.query(X, k=(num_neighbors + 1), + p=_dist_translation['scipy-ckdtree'][dist_type]) + return NN, D + + def _knn_flann(X, num_neighbors, dist_type, order): # the combination FLANN + max_dist produces incorrect results # do not allow it @@ -66,9 +78,27 @@ def _knn_flann(X, num_neighbors, dist_type, order): def _radius_sp_kdtree(X, epsilon, dist_type, order=0): kdt = sps.KDTree(X) D, NN = kdt.query(X, k=None, distance_upper_bound=epsilon, - p=_dist_translation['scipy-kdtree'][dist_type]) + p=_dist_translation['scipy-kdtree'][dist_type]) return NN, D +def _radius_sp_ckdtree(X, epsilon, dist_type, order=0): + N, dim = np.shape(X) + kdt = sps.cKDTree(X) + nn = kdt.query_ball_point(X, r=epsilon, + p=_dist_translation['scipy-ckdtree'][dist_type]) + D = [] + NN = [] + for k in range(N): + x = np.matlib.repmat(X[k, :], len(nn[k]), 1) + d = np.linalg.norm(x - X[nn[k], :], + ord=_dist_translation['scipy-ckdtree'][dist_type], + axis=1) + nidx = d.argsort() + NN.append(np.take(nn[k], nidx)) + D.append(np.sort(d)) + return NN, D + + def _knn_sp_pdist(X, num_neighbors, dist_type, order): pd = sps.distance.squareform( sps.distance.pdist(X, @@ -142,7 +172,8 @@ class NNGraph(Graph): is 'knn'). backend : {'scipy-kdtree', 'scipy-pdist', 'flann'} Type of the backend for graph construction. - - 'scipy-kdtree'(default) will use scipy.spatial.KDTree + - 'scipy-kdtree' will use scipy.spatial.KDTree + - 'scipy-ckdtree'(default) will use scipy.spatial.cKDTree - 'scipy-pdist' will use scipy.spatial.distance.pdist (slowest but exact) - 'flann' use Fast Library for Approximate Nearest Neighbors (FLANN) center : bool, optional @@ -183,7 +214,7 @@ class NNGraph(Graph): """ - def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, + def __init__(self, Xin, NNtype='knn', backend='scipy-ckdtree', center=True, rescale=True, k=10, sigma=0.1, epsilon=0.01, gtype=None, plotting={}, symmetrize_type='average', dist_type='euclidean', order=0, **kwargs): @@ -197,15 +228,18 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-kdtree', center=True, self.sigma = sigma self.epsilon = epsilon _dist_translation['scipy-kdtree']['minkowski'] = order + _dist_translation['scipy-ckdtree']['minkowski'] = order self._nn_functions = { 'knn': { 'scipy-kdtree': _knn_sp_kdtree, + 'scipy-ckdtree': _knn_sp_ckdtree, 'scipy-pdist': _knn_sp_pdist, 'flann': _knn_flann }, 'radius': { 'scipy-kdtree': _radius_sp_kdtree, + 'scipy-ckdtree': _radius_sp_ckdtree, 'scipy-pdist': _radius_sp_pdist, 'flann': _radius_flann }, diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8d1a008d..e8ac8f99 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -183,7 +183,7 @@ def test_set_coordinates(self): def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-pdist', 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'flann'] order=3 # for minkowski, FLANN only accepts integer orders for cur_backend in backends: @@ -194,9 +194,10 @@ def test_nngraph(self): NNtype='knn', backend=cur_backend, dist_type=dist_type) else: - graphs.NNGraph(Xin, NNtype='radius', - backend=cur_backend, - dist_type=dist_type, order=order) + if cur_backend != 'flann': #pyflann fails on radius query + graphs.NNGraph(Xin, NNtype='radius', + backend=cur_backend, + dist_type=dist_type, order=order) graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type, order=order) @@ -208,9 +209,9 @@ def test_nngraph(self): dist_type=dist_type) def test_nngraph_consistency(self): - Xin = np.random.uniform(-5, 5, (60, 3)) + Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann'] num_neighbors=4 epsilon=0.1 From 648fa9130007f8e005808be3d11a92370c0f4410 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Thu, 22 Mar 2018 11:20:40 +0100 Subject: [PATCH 021/365] backport README changes from master --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 99d27364..387daa92 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,7 @@ The documentation is available on `Read the Docs `_ and development takes place on `GitHub `_. -(A `Matlab counterpart `_ exists.) +(A (mostly unmaintained) `Matlab version `_ exists.) The PyGSP facilitates a wide variety of operations on graphs, like computing their Fourier basis, filtering or interpolating signals, plotting graphs, From 8e7c553249f4b762078ceb6ae593d998ccbf3b4f Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Fri, 23 Mar 2018 10:00:13 +0100 Subject: [PATCH 022/365] add nmslib --- pygsp/graphs/nngraphs/nngraph.py | 39 ++++++++++++++++++++++++++++---- pygsp/tests/test_graphs.py | 24 ++++++++++++++------ setup.py | 4 +++- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index d8d85b34..b58c8fc5 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -30,7 +30,12 @@ 'max_dist': 'chebyshev', 'minkowski': 'minkowski' }, - + 'nmslib' : { + 'euclidean': 'l2', + 'manhattan': 'l1', + 'max_dist': 'linf', + 'minkowski': 'lp' + } } def _import_cfl(): @@ -42,7 +47,14 @@ def _import_cfl(): 'pip (or conda) install cyflann.') return cfl - +def _import_nmslib(): + try: + import nmslib as nms + except Exception: + raise ImportError('Cannot import nmslib. Choose another nearest ' + 'neighbors method or try to install it with ' + 'pip (or conda) install nmslib.') + return nms def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): kdt = sps.KDTree(X) @@ -111,6 +123,20 @@ def _knn_sp_pdist(X, num_neighbors, dist_type, order): pdi = pd.argsort()[:, 0:num_neighbors+1] return pdi, pds +def _knn_nmslib(X, num_neighbors, dist_type, order): + N, dim = np.shape(X) + if dist_type == 'minkowski': + raise ValueError('unsupported distance type (lp) for nmslib') + nms = _import_nmslib() + nmsidx = nms.init(space=_dist_translation['nmslib'][dist_type]) + nmsidx.addDataPointBatch(X) + nmsidx.createIndex() + q = nmsidx.knnQueryBatch(X, k=num_neighbors+1) + nn, d = zip(*q) + D = np.concatenate(d).reshape(N, num_neighbors+1) + NN = np.concatenate(nn).reshape(N, num_neighbors+1) + return NN, D + def _radius_sp_pdist(X, epsilon, dist_type, order): N, dim = np.shape(X) if dist_type == 'minkowski': @@ -153,6 +179,9 @@ def _radius_flann(X, epsilon, dist_type, order=0): return NN, list(map(np.sqrt, D)) return NN, D +def _radius_nmslib(X, epsilon, dist_type, order=0): + raise ValueError('nmslib does not support (yet?) range queries') + def center_input(X, N): return X - np.kron(np.ones((N, 1)), np.mean(X, axis=0)) @@ -239,13 +268,15 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-ckdtree', center=True, 'scipy-kdtree': _knn_sp_kdtree, 'scipy-ckdtree': _knn_sp_ckdtree, 'scipy-pdist': _knn_sp_pdist, - 'flann': _knn_flann + 'flann': _knn_flann, + 'nmslib': _knn_nmslib }, 'radius': { 'scipy-kdtree': _radius_sp_kdtree, 'scipy-ckdtree': _radius_sp_ckdtree, 'scipy-pdist': _radius_sp_pdist, - 'flann': _radius_flann + 'flann': _radius_flann, + 'nmslib': _radius_nmslib }, } diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8613d3b1..25ae17b4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -183,24 +183,30 @@ def test_set_coordinates(self): def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib'] order=3 # for minkowski, FLANN only accepts integer orders for cur_backend in backends: for dist_type in dist_types: #print("backend={} dist={}".format(cur_backend, dist_type)) - if cur_backend == 'flann' and dist_type == 'max_dist': + if (cur_backend == 'flann' and + dist_type == 'max_dist') or (cur_backend == 'nmslib' and + dist_type == 'minkowski'): self.assertRaises(ValueError, graphs.NNGraph, Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type) else: - if cur_backend != 'flann': #pyflann fails on radius query + if cur_backend == 'nmslib': + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='radius', backend=cur_backend, + dist_type=dist_type, order=order) + else: graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, dist_type=dist_type, order=order) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type, order=order) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, + dist_type=dist_type, order=order) self.assertRaises(ValueError, graphs.NNGraph, Xin, NNtype='badtype', backend=cur_backend, dist_type=dist_type) @@ -211,7 +217,7 @@ def test_nngraph(self): def test_nngraph_consistency(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'nmslib']#, 'flann'] num_neighbors=4 epsilon=0.1 @@ -222,6 +228,8 @@ def test_nngraph_consistency(self): for dist_type in dist_types: if cur_backend == 'flann' and dist_type == 'max_dist': continue + if cur_backend == 'nmslib' and dist_type == 'minkowski': + continue #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, k=num_neighbors) @@ -234,6 +242,8 @@ def test_nngraph_consistency(self): for dist_type in dist_types: if cur_backend == 'flann' and dist_type == 'max_dist': continue + if cur_backend == 'nmslib': #unsupported + continue #print("backend={} dist={}".format(cur_backend, dist_type)) Gt = graphs.NNGraph(Xin, NNtype='radius', backend=cur_backend, epsilon=epsilon) diff --git a/setup.py b/setup.py index a9f92004..2979ac83 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,9 @@ # Construct patch graphs from images. 'scikit-image', # Approximate nearest neighbors for kNN graphs. - 'cyflann', + 'cyflann', + 'pybind11', + 'nmslib', # Convex optimization on graph. 'pyunlocbox', # Plot graphs, signals, and filters. From b83e4672e801bf88ca0de6468b4e9b33a1995e2b Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 26 Mar 2018 10:12:43 +0200 Subject: [PATCH 023/365] test flann when not on windows --- pygsp/tests/test_graphs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 25ae17b4..14277ed4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -6,7 +6,7 @@ """ import unittest - +import os import numpy as np import scipy.linalg import scipy.sparse.linalg @@ -183,7 +183,9 @@ def test_set_coordinates(self): def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib'] + if os.name != 'nt': + backends.append('flann') order=3 # for minkowski, FLANN only accepts integer orders for cur_backend in backends: @@ -217,7 +219,9 @@ def test_nngraph(self): def test_nngraph_consistency(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'nmslib']#, 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'nmslib'] + if os.name != 'nt': + backends.append('flann') num_neighbors=4 epsilon=0.1 From 28b78589c6dbd9d6149c2c8dfeba39a794282690 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Thu, 29 Mar 2018 10:56:41 +0200 Subject: [PATCH 024/365] use the same code to build sparse matrix for knn and radius --- pygsp/graphs/nngraphs/nngraph.py | 41 ++++++++++++-------------------- 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index d8d85b34..b35b6d38 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -273,37 +273,26 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-ckdtree', center=True, raise ValueError('Invalid backend {} for type {}'.format(backend, self.NNtype)) if self.NNtype == 'knn': - spi = np.zeros((N * k)) - spj = np.zeros((N * k)) - spv = np.zeros((N * k)) - NN, D = self._nn_functions[NNtype][backend](Xout, k, dist_type, order) - - for i in range(N): - spi[i * k:(i + 1) * k] = np.kron(np.ones((k)), i) - spj[i * k:(i + 1) * k] = NN[i, 1:] - spv[i * k:(i + 1) * k] = np.exp(-np.power(D[i, 1:], 2) / - float(self.sigma)) - + elif self.NNtype == 'radius': - NN, D = self._nn_functions[NNtype][backend](Xout, epsilon, dist_type, order) - count = sum(map(len, NN)) - - spi = np.zeros((count)) - spj = np.zeros((count)) - spv = np.zeros((count)) - - start = 0 - for i in range(N): - leng = len(NN[i]) - 1 - spi[start:start + leng] = np.kron(np.ones((leng)), i) - spj[start:start + leng] = NN[i][1:] - spv[start:start + leng] = np.exp(-np.power(D[i][1:], 2) / - float(self.sigma)) - start = start + leng + countV = list(map(len, NN)) + count = sum(countV) + spi = np.zeros((count)) + spj = np.zeros((count)) + spv = np.zeros((count)) + + start = 0 + for i in range(N): + leng = countV[i] - 1 + spi[start:start + leng] = np.kron(np.ones((leng)), i) + spj[start:start + leng] = NN[i][1:] + spv[start:start + leng] = np.exp(-np.power(D[i][1:], 2) / + float(self.sigma)) + start = start + leng W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) From 188c4a61ffe93fa0647030758c31ebfe21a2f163 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Thu, 29 Mar 2018 13:44:10 +0200 Subject: [PATCH 025/365] building the graph with rescale/center=False should also work --- pygsp/tests/test_graphs.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 14277ed4..a48e6034 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -197,6 +197,9 @@ def test_nngraph(self): self.assertRaises(ValueError, graphs.NNGraph, Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type) + self.assertRaises(ValueError, graphs.NNGraph, Xin, + NNtype='radius', backend=cur_backend, + dist_type=dist_type) else: if cur_backend == 'nmslib': self.assertRaises(ValueError, graphs.NNGraph, Xin, @@ -209,6 +212,18 @@ def test_nngraph(self): graphs.NNGraph(Xin, NNtype='knn', backend=cur_backend, dist_type=dist_type, order=order) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, + dist_type=dist_type, order=order, + center=False) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, + dist_type=dist_type, order=order, + rescale=False) + graphs.NNGraph(Xin, NNtype='knn', + backend=cur_backend, + dist_type=dist_type, order=order, + rescale=False, center=False) self.assertRaises(ValueError, graphs.NNGraph, Xin, NNtype='badtype', backend=cur_backend, dist_type=dist_type) From 8e98b77b0f8d26f83ef10064e454a14811796539 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Thu, 29 Mar 2018 14:58:25 +0200 Subject: [PATCH 026/365] update doc for nmslib --- pygsp/graphs/nngraphs/nngraph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index a5f766e8..0ba86c92 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -209,6 +209,7 @@ class NNGraph(Graph): - 'scipy-ckdtree'(default) will use scipy.spatial.cKDTree - 'scipy-pdist' will use scipy.spatial.distance.pdist (slowest but exact) - 'flann' use Fast Library for Approximate Nearest Neighbors (FLANN) + - 'nmslib' use nmslib for approximate nearest neighbors (faster in high-dimensional spaces) center : bool, optional Center the data so that it has zero mean (default is True) rescale : bool, optional From 08ae29fae719af6e551b531c381d52cf747e8ea0 Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Mon, 9 Apr 2018 09:46:45 +0200 Subject: [PATCH 027/365] enable multithreading with ckdtree/nmslib --- pygsp/graphs/nngraphs/nngraph.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 0ba86c92..5d564089 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -6,6 +6,7 @@ from scipy import sparse import scipy.spatial as sps import scipy.spatial.distance as spsd +import multiprocessing from pygsp import utils from pygsp.graphs import Graph # prevent circular import in Python < 3.5 @@ -65,7 +66,8 @@ def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): def _knn_sp_ckdtree(X, num_neighbors, dist_type, order=0): kdt = sps.cKDTree(X) D, NN = kdt.query(X, k=(num_neighbors + 1), - p=_dist_translation['scipy-ckdtree'][dist_type]) + p=_dist_translation['scipy-ckdtree'][dist_type], + n_jobs=-1) return NN, D @@ -98,7 +100,8 @@ def _radius_sp_ckdtree(X, epsilon, dist_type, order=0): N, dim = np.shape(X) kdt = sps.cKDTree(X) nn = kdt.query_ball_point(X, r=epsilon, - p=_dist_translation['scipy-ckdtree'][dist_type]) + p=_dist_translation['scipy-ckdtree'][dist_type], + n_jobs=-1) D = [] NN = [] for k in range(N): @@ -125,13 +128,14 @@ def _knn_sp_pdist(X, num_neighbors, dist_type, order): def _knn_nmslib(X, num_neighbors, dist_type, order): N, dim = np.shape(X) + ncpu = multiprocessing.cpu_count() if dist_type == 'minkowski': raise ValueError('unsupported distance type (lp) for nmslib') nms = _import_nmslib() nmsidx = nms.init(space=_dist_translation['nmslib'][dist_type]) nmsidx.addDataPointBatch(X) nmsidx.createIndex() - q = nmsidx.knnQueryBatch(X, k=num_neighbors+1) + q = nmsidx.knnQueryBatch(X, k=num_neighbors+1, num_threads=int(ncpu/2)) nn, d = zip(*q) D = np.concatenate(d).reshape(N, num_neighbors+1) NN = np.concatenate(nn).reshape(N, num_neighbors+1) From a562896516c75514c1522337e12188c83f9dd57c Mon Sep 17 00:00:00 2001 From: Nicolas Aspert Date: Wed, 20 Jun 2018 11:45:33 +0200 Subject: [PATCH 028/365] fix _get_extra_repr --- pygsp/graphs/nngraphs/nngraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 73449c6c..ec91afe5 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -345,7 +345,7 @@ def __init__(self, Xin, NNtype='knn', backend='scipy-ckdtree', center=True, def _get_extra_repr(self): return {'NNtype': self.NNtype, - 'use_flann': self.use_flann, + 'backend': self.backend, 'center': self.center, 'rescale': self.rescale, 'k': self.k, From 8a51649f2435b50381d4b11fc4718a63861be0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Feb 2019 01:00:40 +0100 Subject: [PATCH 029/365] NNGraph: clean and doc (PR #21) --- README.rst | 2 +- pygsp/graphs/nngraphs/bunny.py | 4 +- pygsp/graphs/nngraphs/cube.py | 2 +- pygsp/graphs/nngraphs/imgpatches.py | 8 +- pygsp/graphs/nngraphs/nngraph.py | 507 ++++++++++++++-------------- pygsp/graphs/nngraphs/sensor.py | 2 +- pygsp/graphs/nngraphs/sphere.py | 2 +- pygsp/graphs/nngraphs/twomoons.py | 30 +- pygsp/tests/test_graphs.py | 146 ++++---- pygsp/tests/test_plotting.py | 4 +- setup.py | 3 +- 11 files changed, 359 insertions(+), 351 deletions(-) diff --git a/README.rst b/README.rst index a768428a..7f0b8776 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ The documentation is available on `Read the Docs `_ and development takes place on `GitHub `_. -(A (mostly unmaintained) `Matlab version `_ exists.) +A (mostly unmaintained) `Matlab version `_ exists. The PyGSP facilitates a wide variety of operations on graphs, like computing their Fourier basis, filtering or interpolating signals, plotting graphs, diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index d9dac407..21729823 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -34,7 +34,7 @@ def __init__(self, **kwargs): 'distance': 8, } - super(Bunny, self).__init__(Xin=data['bunny'], - epsilon=0.02, NNtype='radius', + super(Bunny, self).__init__(data['bunny'], center=False, rescale=False, + kind='radius', radius=0.02, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 820e401c..85f51347 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -89,7 +89,7 @@ def __init__(self, 'distance': 9, } - super(Cube, self).__init__(Xin=pts, k=10, + super(Cube, self).__init__(pts, k=10, center=False, rescale=False, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index ea11be06..95ce9149 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -10,7 +10,8 @@ class ImgPatches(NNGraph): Extract a feature vector in the form of a patch for every pixel of an image, then construct a nearest-neighbor graph between these feature - vectors. The feature matrix, i.e. the patches, can be found in :attr:`Xin`. + vectors. The feature matrix, i.e., the patches, can be found in + :attr:`features`. Parameters ---------- @@ -35,9 +36,10 @@ class ImgPatches(NNGraph): >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::64, ::64]) >>> G = graphs.ImgPatches(img, patch_shape=(3, 3)) - >>> print('{} nodes ({} x {} pixels)'.format(G.Xin.shape[0], *img.shape)) + >>> N, d = G.features.shape + >>> print('{} nodes ({} x {} pixels)'.format(N, *img.shape)) 64 nodes (8 x 8 pixels) - >>> print('{} features per node'.format(G.Xin.shape[1])) + >>> print('{} features per node'.format(d)) 9 features per node >>> G.set_coordinates(kind='spring', seed=42) >>> fig, axes = plt.subplots(1, 2) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index c099a546..85b7ab43 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -1,44 +1,51 @@ # -*- coding: utf-8 -*- +from __future__ import division + +import multiprocessing import traceback import numpy as np -import numpy.matlib -from scipy import sparse -import scipy.spatial as sps -import scipy.spatial.distance as spsd -import multiprocessing +from scipy import sparse, spatial from pygsp import utils from pygsp.graphs import Graph # prevent circular import in Python < 3.5 -_logger = utils.build_logger(__name__) # conversion between the FLANN conventions and the various backend functions -_dist_translation = { - 'scipy-kdtree': { - 'euclidean': 2, - 'manhattan': 1, - 'max_dist': np.inf - }, - 'scipy-ckdtree': { - 'euclidean': 2, - 'manhattan': 1, - 'max_dist': np.inf - }, - 'scipy-pdist' : { - 'euclidean': 'euclidean', - 'manhattan': 'cityblock', - 'max_dist': 'chebyshev', - 'minkowski': 'minkowski' - }, - 'nmslib' : { - 'euclidean': 'l2', - 'manhattan': 'l1', - 'max_dist': 'linf', - 'minkowski': 'lp' - } - } +_metrics = { + 'scipy-pdist': { + 'euclidean': 'euclidean', + 'manhattan': 'cityblock', + 'max_dist': 'chebyshev', + 'minkowski': 'minkowski', + }, + 'scipy-kdtree': { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf, + 'minkowski': 0, + }, + 'scipy-ckdtree': { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf, + 'minkowski': 0, + }, + 'flann': { + 'euclidean': 'euclidean', + 'manhattan': 'manhattan', +# 'max_dist': 'max_dist', # produces incorrect results + 'minkowski': 'minkowski', + }, + 'nmslib': { + 'euclidean': 'l2', + 'manhattan': 'l1', + 'max_dist': 'linf', +# 'minkowski': 'lp', # unsupported + } +} + def _import_cfl(): try: @@ -50,6 +57,7 @@ def _import_cfl(): 'Original exception: {}'.format(e)) return cfl + def _import_nmslib(): try: import nmslib as nms @@ -58,58 +66,55 @@ def _import_nmslib(): 'neighbors method or try to install it with ' 'pip (or conda) install nmslib.') return nms - -def _knn_sp_kdtree(X, num_neighbors, dist_type, order=0): - kdt = sps.KDTree(X) - D, NN = kdt.query(X, k=(num_neighbors + 1), - p=_dist_translation['scipy-kdtree'][dist_type]) + + +def _knn_sp_kdtree(features, num_neighbors, metric, order): + p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] + kdt = spatial.KDTree(features) + D, NN = kdt.query(features, k=(num_neighbors + 1), p=p) return NN, D -def _knn_sp_ckdtree(X, num_neighbors, dist_type, order=0): - kdt = sps.cKDTree(X) - D, NN = kdt.query(X, k=(num_neighbors + 1), - p=_dist_translation['scipy-ckdtree'][dist_type], - n_jobs=-1) + +def _knn_sp_ckdtree(features, num_neighbors, metric, order): + p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] + kdt = spatial.cKDTree(features) + D, NN = kdt.query(features, k=(num_neighbors + 1), p=p, n_jobs=-1) return NN, D -def _knn_flann(X, num_neighbors, dist_type, order): - # the combination FLANN + max_dist produces incorrect results - # do not allow it - if dist_type == 'max_dist': - raise ValueError('FLANN and max_dist is not supported') - +def _knn_flann(features, num_neighbors, metric, order): cfl = _import_cfl() - cfl.set_distance_type(dist_type, order=order) + cfl.set_distance_type(metric, order=order) c = cfl.FLANNIndex(algorithm='kdtree') - c.build_index(X) + c.build_index(features) # Default FLANN parameters (I tried changing the algorithm and # testing performance on huge matrices, but the default one # seems to work best). - NN, D = c.nn_index(X, num_neighbors + 1) + NN, D = c.nn_index(features, num_neighbors + 1) c.free_index() - if dist_type == 'euclidean': # flann returns squared distances + if metric == 'euclidean': # flann returns squared distances return NN, np.sqrt(D) return NN, D -def _radius_sp_kdtree(X, epsilon, dist_type, order=0): - kdt = sps.KDTree(X) - D, NN = kdt.query(X, k=None, distance_upper_bound=epsilon, - p=_dist_translation['scipy-kdtree'][dist_type]) + +def _radius_sp_kdtree(features, radius, metric, order): + p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] + kdt = spatial.KDTree(features) + D, NN = kdt.query(features, k=None, distance_upper_bound=radius, p=p) return NN, D -def _radius_sp_ckdtree(X, epsilon, dist_type, order=0): - N, dim = np.shape(X) - kdt = sps.cKDTree(X) - nn = kdt.query_ball_point(X, r=epsilon, - p=_dist_translation['scipy-ckdtree'][dist_type], - n_jobs=-1) + +def _radius_sp_ckdtree(features, radius, metric, order): + p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] + n_vertices, _ = features.shape + kdt = spatial.cKDTree(features) + nn = kdt.query_ball_point(features, r=radius, p=p, n_jobs=-1) D = [] NN = [] - for k in range(N): - x = np.matlib.repmat(X[k, :], len(nn[k]), 1) - d = np.linalg.norm(x - X[nn[k], :], - ord=_dist_translation['scipy-ckdtree'][dist_type], + for k in range(n_vertices): + x = np.tile(features[k, :], (len(nn[k]), 1)) + d = np.linalg.norm(x - features[nn[k], :], + ord=_metrics['scipy-ckdtree'][metric], axis=1) nidx = d.argsort() NN.append(np.take(nn[k], nidx)) @@ -117,250 +122,260 @@ def _radius_sp_ckdtree(X, epsilon, dist_type, order=0): return NN, D -def _knn_sp_pdist(X, num_neighbors, dist_type, order): - if dist_type == 'minkowski': - p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type], - p=order) - else: - p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type]) - pd = spsd.squareform(p) - pds = np.sort(pd)[:, 0:num_neighbors+1] - pdi = pd.argsort()[:, 0:num_neighbors+1] +def _knn_sp_pdist(features, num_neighbors, metric, order): + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric], + p=order) + pd = spatial.distance.squareform(p) + pds = np.sort(pd)[:, :num_neighbors+1] + pdi = pd.argsort()[:, :num_neighbors+1] return pdi, pds - -def _knn_nmslib(X, num_neighbors, dist_type, order): - N, dim = np.shape(X) + + +def _knn_nmslib(features, num_neighbors, metric, _): + n_vertices, _ = features.shape ncpu = multiprocessing.cpu_count() - if dist_type == 'minkowski': - raise ValueError('unsupported distance type (lp) for nmslib') nms = _import_nmslib() - nmsidx = nms.init(space=_dist_translation['nmslib'][dist_type]) - nmsidx.addDataPointBatch(X) + nmsidx = nms.init(space=_metrics['nmslib'][metric]) + nmsidx.addDataPointBatch(features) nmsidx.createIndex() - q = nmsidx.knnQueryBatch(X, k=num_neighbors+1, num_threads=int(ncpu/2)) + q = nmsidx.knnQueryBatch(features, k=num_neighbors+1, + num_threads=int(ncpu/2)) nn, d = zip(*q) - D = np.concatenate(d).reshape(N, num_neighbors+1) - NN = np.concatenate(nn).reshape(N, num_neighbors+1) + D = np.concatenate(d).reshape(n_vertices, num_neighbors+1) + NN = np.concatenate(nn).reshape(n_vertices, num_neighbors+1) return NN, D - -def _radius_sp_pdist(X, epsilon, dist_type, order): - N, dim = np.shape(X) - if dist_type == 'minkowski': - p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type], - p=order) - else: - p = spsd.pdist(X, metric=_dist_translation['scipy-pdist'][dist_type]) - pd = spsd.squareform(p) - pdf = pd < epsilon + + +def _radius_sp_pdist(features, radius, metric, order): + n_vertices, _ = np.shape(features) + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric], + p=order) + pd = spatial.distance.squareform(p) + pdf = pd < radius D = [] NN = [] - for k in range(N): + for k in range(n_vertices): v = pd[k, pdf[k, :]] d = pd[k, :].argsort() # use the same conventions as in scipy.distance.kdtree NN.append(d[0:len(v)]) D.append(np.sort(v)) - + return NN, D -def _radius_flann(X, epsilon, dist_type, order=0): - N, dim = np.shape(X) - # the combination FLANN + max_dist produces incorrect results - # do not allow it - if dist_type == 'max_dist': - raise ValueError('FLANN and max_dist is not supported') - + +def _radius_flann(features, radius, metric, order): + n_vertices, _ = features.shape cfl = _import_cfl() - cfl.set_distance_type(dist_type, order=order) + cfl.set_distance_type(metric, order=order) c = cfl.FLANNIndex(algorithm='kdtree') - c.build_index(X) + c.build_index(features) D = [] NN = [] - for k in range(N): - nn, d = c.nn_radius(X[k, :], epsilon*epsilon) + for k in range(n_vertices): + nn, d = c.nn_radius(features[k, :], radius**2) D.append(d) NN.append(nn) c.free_index() - if dist_type == 'euclidean': # flann returns squared distances - return NN, list(map(np.sqrt, D)) + if metric == 'euclidean': + # Flann returns squared distances. + D = list(map(np.sqrt, D)) return NN, D -def _radius_nmslib(X, epsilon, dist_type, order=0): - raise ValueError('nmslib does not support (yet?) range queries') -def center_input(X, N): - return X - np.kron(np.ones((N, 1)), np.mean(X, axis=0)) +_nn_functions = { + 'knn': { + 'scipy-pdist': _knn_sp_pdist, + 'scipy-kdtree': _knn_sp_kdtree, + 'scipy-ckdtree': _knn_sp_ckdtree, + 'flann': _knn_flann, + 'nmslib': _knn_nmslib, + }, + 'radius': { + 'scipy-pdist': _radius_sp_pdist, + 'scipy-kdtree': _radius_sp_kdtree, + 'scipy-ckdtree': _radius_sp_ckdtree, + 'flann': _radius_flann, + }, +} + + +def center_features(features): + n_vertices, _ = features.shape + return features - np.kron(np.ones((n_vertices, 1)), np.mean(features, 0)) + + +def rescale_features(features): + n_vertices, dimensionality = features.shape + bounding_radius = 0.5 * np.linalg.norm(np.amax(features, axis=0) - + np.amin(features, axis=0), 2) + scale = np.power(n_vertices, 1 / min(dimensionality, 3)) / 10 + return features * scale / bounding_radius -def rescale_input(X, N, d): - bounding_radius = 0.5 * np.linalg.norm(np.amax(X, axis=0) - - np.amin(X, axis=0), 2) - scale = np.power(N, 1. / float(min(d, 3))) / 10. - return X * scale / bounding_radius class NNGraph(Graph): - r"""Nearest-neighbor graph from given point cloud. + r"""Nearest-neighbor graph. + + The nearest-neighbor graph is built from a set of features, where the edge + weight between vertices :math:`v_i` and :math:`v_j` is given by + + .. math:: A(i,j) = \exp \left( -\frac{d^2(v_i, v_j)}{\sigma^2} \right), + + where :math:`d(v_i, v_j)` is a distance measure between some representation + (the features) of :math:`v_i` and :math:`v_j`. For example, the features + might be the 3D coordinates of points in a point cloud. Then, if + ``metric='euclidean'``, :math:`d(v_i, v_j) = \| x_i - x_j \|_2`, where + :math:`x_i` is the 3D position of vertex :math:`v_i`. + + The similarity matrix :math:`A` is sparsified by either keeping the ``k`` + closest vertices for each vertex (if ``type='knn'``), or by setting to zero + any distance greater than ``radius`` (if ``type='radius'``). Parameters ---------- - Xin : ndarray - Input points, Should be an `N`-by-`d` matrix, where `N` is the number - of nodes in the graph and `d` is the dimension of the feature space. - NNtype : string, optional - Type of nearest neighbor graph to create. The options are 'knn' for - k-Nearest Neighbors or 'radius' for epsilon-Nearest Neighbors (default - is 'knn'). - backend : {'scipy-kdtree', 'scipy-pdist', 'flann'} - Type of the backend for graph construction. - - 'scipy-kdtree' will use scipy.spatial.KDTree - - 'scipy-ckdtree'(default) will use scipy.spatial.cKDTree - - 'scipy-pdist' will use scipy.spatial.distance.pdist (slowest but exact) - - 'flann' use Fast Library for Approximate Nearest Neighbors (FLANN) - - 'nmslib' use nmslib for approximate nearest neighbors (faster in high-dimensional spaces) + features : ndarray + An `N`-by-`d` matrix, where `N` is the number of nodes in the graph and + `d` is the number of features. center : bool, optional - Center the data so that it has zero mean (default is True) + Whether to center the features to have zero mean. rescale : bool, optional - Rescale the data so that it lies in a l2-sphere (default is True) - k : int, optional - Number of neighbors for knn (default is 10) - sigma : float, optional - Width of the similarity kernel. - By default, it is set to the average of the nearest neighbor distance. - epsilon : float, optional - Radius for the epsilon-neighborhood search (default is 0.01) - plotting : dict, optional - Dictionary of plotting parameters. See :obj:`pygsp.plotting`. - (default is {}) - symmetrize_type : string, optional - Type of symmetrization to use for the adjacency matrix. See - :func:`pygsp.utils.symmetrization` for the options. - (default is 'average') - dist_type : string, optional - Type of distance to compute. See - :func:`pyflann.index.set_distance_type` for possible options. - (default is 'euclidean') + Whether to scale the features so that they lie in an l2-ball. + metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional + Metric used to compute pairwise distances. order : float, optional - Only used if dist_type is 'minkowski'; represents the order of the - Minkowski distance. (default is 0) + The order of the Minkowski distance for ``metric='minkowski'``. + kind : {'knn', 'radius'}, optional + Kind of nearest neighbor graph to create. Either ``'knn'`` for + k-nearest neighbors or ``'radius'`` for epsilon-nearest neighbors. + k : int, optional + Number of neighbors considered when building a k-NN graph with + ``type='knn'``. + radius : float, optional + Radius of the ball when building a radius graph with ``type='radius'``. + kernel_width : float, optional + Width of the Gaussian kernel. By default, it is set to the average of + the distances of neighboring vertices. + backend : string, optional + * ``'scipy-pdist'`` uses :func:`scipy.spatial.distance.pdist` to + compute pairwise distances. The method is brute force and computes + all distances. That is the slowest method. + * ``'scipy-kdtree'`` uses :class:`scipy.spatial.KDTree`. The method + builds a k-d tree to prune the number of pairwise distances it has to + compute. + * ``'scipy-ckdtree'`` uses :class:`scipy.spatial.cKDTree`. The same as + ``'scipy-kdtree'`` but with C bindings, which should be faster. + * ``'flann'`` uses the `Fast Library for Approximate Nearest Neighbors + (FLANN) `_. That method is an + approximation. + * ``'nmslib'`` uses the `Non-Metric Space Library (NMSLIB) + `_. That method is an + approximation. It should be the fastest in high-dimensional spaces. Examples -------- >>> import matplotlib.pyplot as plt - >>> X = np.random.RandomState(42).uniform(size=(30, 2)) - >>> G = graphs.NNGraph(X) + >>> features = np.random.RandomState(42).uniform(size=(30, 2)) + >>> G = graphs.NNGraph(features) >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=5) >>> _ = G.plot(ax=axes[1]) """ - def __init__(self, Xin, NNtype='knn', backend='scipy-ckdtree', center=True, - rescale=True, k=10, sigma=None, epsilon=0.01, - plotting={}, symmetrize_type='average', dist_type='euclidean', - order=0, **kwargs): + def __init__(self, features, *, center=True, rescale=True, + metric='euclidean', order=0, + kind='knn', k=10, radius=0.01, + kernel_width=None, + backend='scipy-ckdtree', + **kwargs): - self.Xin = Xin - self.NNtype = NNtype - self.backend = backend + self.features = features # stored in coords, but scaled and centered self.center = center self.rescale = rescale - self.k = k - self.sigma = sigma - self.epsilon = epsilon - - _dist_translation['scipy-kdtree']['minkowski'] = order - _dist_translation['scipy-ckdtree']['minkowski'] = order - - self._nn_functions = { - 'knn': { - 'scipy-kdtree': _knn_sp_kdtree, - 'scipy-ckdtree': _knn_sp_ckdtree, - 'scipy-pdist': _knn_sp_pdist, - 'flann': _knn_flann, - 'nmslib': _knn_nmslib - }, - 'radius': { - 'scipy-kdtree': _radius_sp_kdtree, - 'scipy-ckdtree': _radius_sp_ckdtree, - 'scipy-pdist': _radius_sp_pdist, - 'flann': _radius_flann, - 'nmslib': _radius_nmslib - }, - } - - - - self.symmetrize_type = symmetrize_type - self.dist_type = dist_type + self.metric = metric self.order = order + self.kind = kind + self.k = k + self.radius = radius + self.kernel_width = kernel_width + self.backend = backend + + N, d = np.shape(features) + + if _nn_functions.get(kind) is None: + raise ValueError('Invalid kind "{}".'.format(kind)) - N, d = np.shape(self.Xin) - Xout = self.Xin + if backend not in _metrics.keys(): + raise ValueError('Invalid backend "{}".'.format(backend)) - if k >= N: + if _metrics['scipy-pdist'].get(metric) is None: + raise ValueError('Invalid metric "{}".'.format(metric)) + + if _nn_functions[kind].get(backend) is None: + raise ValueError('{} does not support kind "{}".'.format( + backend, kind)) + + if _metrics[backend].get(metric) is None: + raise ValueError('{} does not support the {} metric.'.format( + backend, metric)) + + if kind == 'knn' and k >= N: raise ValueError('The number of neighbors (k={}) must be smaller ' - 'than the number of nodes ({}).'.format(k, N)) - - if self.center: - Xout = center_input(Xout, N) - - if self.rescale: - Xout = rescale_input(Xout, N, d) - - if self._nn_functions.get(NNtype) == None: - raise ValueError('Invalid NNtype {}'.format(self.NNtype)) - - if self._nn_functions[NNtype].get(backend) == None: - raise ValueError('Invalid backend {} for type {}'.format(backend, - self.NNtype)) - - if self.NNtype == 'knn': - NN, D = self._nn_functions[NNtype][backend](Xout, k, - dist_type, order) - if self.sigma is None: - self.sigma = np.mean(D[:, 1:]) # Discard distance to self. - - elif self.NNtype == 'radius': - NN, D = self._nn_functions[NNtype][backend](Xout, epsilon, - dist_type, order) - if self.sigma is None: + 'than the number of vertices ({}).'.format(k, N)) + + if center: + features = center_features(features) + + if rescale: + features = rescale_features(features) + + if kind == 'knn': + NN, D = _nn_functions[kind][backend](features, k, metric, order) + if self.kernel_width is None: + # Discard distance to self. + self.kernel_width = np.mean(D[:, 1:]) + + elif kind == 'radius': + NN, D = _nn_functions[kind][backend](features, radius, + metric, order) + if self.kernel_width is None: # Discard distance to self. - self.sigma = np.mean([np.mean(d[1:]) for d in D]) + self.kernel_width = np.mean([np.mean(d[1:]) for d in D]) + countV = list(map(len, NN)) - count = sum(countV) + count = sum(countV) spi = np.zeros((count)) spj = np.zeros((count)) spv = np.zeros((count)) start = 0 for i in range(N): - leng = countV[i] - 1 - spi[start:start + leng] = np.kron(np.ones((leng)), i) - spj[start:start + leng] = NN[i][1:] - spv[start:start + leng] = np.exp(-np.power(D[i][1:], 2) / - float(self.sigma)) - start = start + leng + length = countV[i] - 1 + distance = np.power(D[i][1:], 2) + spi[start:start + length] = np.kron(np.ones((length)), i) + spj[start:start + length] = NN[i][1:] + spv[start:start + length] = np.exp(-distance / self.kernel_width) + start = start + length W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) - # Sanity check - if np.shape(W)[0] != np.shape(W)[1]: - raise ValueError('Weight matrix W is not square') - - # Enforce symmetry. Note that checking symmetry with - # np.abs(W - W.T).sum() is as costly as the symmetrization itself. - W = utils.symmetrize(W, method=symmetrize_type) + # Enforce symmetry. May have been broken by k-NN. Checking symmetry + # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. + W = utils.symmetrize(W, method='average') - super(NNGraph, self).__init__(W=W, plotting=plotting, - coords=Xout, **kwargs) + super(NNGraph, self).__init__(W=W, coords=features, **kwargs) def _get_extra_repr(self): - return {'NNtype': self.NNtype, - 'backend': self.backend, - 'center': self.center, - 'rescale': self.rescale, - 'k': self.k, - 'sigma': '{:.2f}'.format(self.sigma), - 'epsilon': '{:.2f}'.format(self.epsilon), - 'symmetrize_type': self.symmetrize_type, - 'dist_type': self.dist_type, - 'order': self.order} + return { + 'center': self.center, + 'rescale': self.rescale, + 'metric': self.metric, + 'order': self.order, + 'kind': self.kind, + 'k': self.k, + 'radius': '{:.2f}'.format(self.radius), + 'kernel_width': '{:.2f}'.format(self.kernel_width), + 'backend': self.backend, + } diff --git a/pygsp/graphs/nngraphs/sensor.py b/pygsp/graphs/nngraphs/sensor.py index ac623a50..5491e0c7 100644 --- a/pygsp/graphs/nngraphs/sensor.py +++ b/pygsp/graphs/nngraphs/sensor.py @@ -75,7 +75,7 @@ def __init__(self, N=64, k=6, distributed=False, seed=None, **kwargs): coords = rs.uniform(0, 1, (N, 2)) - super(Sensor, self).__init__(Xin=coords, k=k, + super(Sensor, self).__init__(coords, k=k, rescale=False, center=False, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index e375b5b4..2ad9c233 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -64,7 +64,7 @@ def __init__(self, 'vertex_size': 80, } - super(Sphere, self).__init__(Xin=pts, k=10, + super(Sphere, self).__init__(pts, k=10, center=False, rescale=False, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index af87d0d8..6da8ca95 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -16,7 +16,7 @@ class TwoMoons(NNGraph): two_moons graph or a synthesized one (default is 'standard'). 'standard' : Create a two_moons graph from a based graph. 'synthesized' : Create a synthesized two_moon - sigmag : float + kernel_width : float Variance of the distance kernel (default = 0.05) dim : int The dimensionality of the points (default = 2). @@ -24,7 +24,7 @@ class TwoMoons(NNGraph): N : int Number of vertices (default = 2000) Only valid for moontype == 'synthesized'. - sigmad : float + variance : float Variance of the data (do not set it too high or you won't see anything) (default = 0.05) Only valid for moontype == 'synthesized'. @@ -44,11 +44,11 @@ class TwoMoons(NNGraph): """ - def _create_arc_moon(self, N, sigmad, distance, number, seed): + def _create_arc_moon(self, N, variance, distance, number, seed): rs = np.random.RandomState(seed) phi = rs.rand(N, 1) * np.pi r = 1 - rb = sigmad * rs.normal(size=(N, 1)) + rb = variance * rs.normal(size=(N, 1)) ab = rs.rand(N, 1) * 2 * np.pi b = rb * np.exp(1j * ab) bx = np.real(b) @@ -63,29 +63,29 @@ def _create_arc_moon(self, N, sigmad, distance, number, seed): return np.concatenate((moonx, moony), axis=1) - def __init__(self, moontype='standard', dim=2, sigmag=0.05, - N=400, sigmad=0.07, distance=0.5, seed=None, **kwargs): + def __init__(self, moontype='standard', dim=2, kernel_width=0.05, + N=400, variance=0.07, distance=0.5, seed=None, **kwargs): self.moontype = moontype self.dim = dim - self.sigmag = sigmag - self.sigmad = sigmad + self.kernel_width = kernel_width + self.variance = variance self.distance = distance self.seed = seed if moontype == 'standard': N1, N2 = 1000, 1000 data = utils.loadmat('pointclouds/two_moons') - Xin = data['features'][:dim].T + coords = data['features'][:dim].T elif moontype == 'synthesized': N1 = N // 2 N2 = N - N1 - coords1 = self._create_arc_moon(N1, sigmad, distance, 1, seed) - coords2 = self._create_arc_moon(N2, sigmad, distance, 2, seed) + coords1 = self._create_arc_moon(N1, variance, distance, 1, seed) + coords2 = self._create_arc_moon(N2, variance, distance, 2, seed) - Xin = np.concatenate((coords1, coords2)) + coords = np.concatenate((coords1, coords2)) else: raise ValueError('Unknown moontype {}'.format(moontype)) @@ -96,15 +96,15 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, 'vertex_size': 30, } - super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, + super(TwoMoons, self).__init__(coords, kernel_width=kernel_width, k=5, center=False, rescale=False, plotting=plotting, **kwargs) def _get_extra_repr(self): attrs = {'moontype': self.moontype, 'dim': self.dim, - 'sigmag': '{:.2f}'.format(self.sigmag), - 'sigmad': '{:.2f}'.format(self.sigmad), + 'kernel_width': '{:.2f}'.format(self.kernel_width), + 'variance': '{:.2f}'.format(self.variance), 'distance': '{:.2f}'.format(self.distance), 'seed': self.seed} attrs.update(super(TwoMoons, self)._get_extra_repr()) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index df23a0db..c516a8a7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -8,7 +8,6 @@ from __future__ import division import unittest -import os import numpy as np import scipy.linalg from scipy import sparse @@ -349,96 +348,89 @@ def test_set_coordinates(self): self.assertRaises(ValueError, G.set_coordinates, 'invalid') def test_nngraph(self, n_vertices=30): - rs = np.random.RandomState(42) - Xin = rs.normal(size=(n_vertices, 3)) - dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib'] - if os.name != 'nt': - backends.append('flann') - order=3 # for minkowski, FLANN only accepts integer orders - - for cur_backend in backends: - for dist_type in dist_types: - #print("backend={} dist={}".format(cur_backend, dist_type)) - if (cur_backend == 'flann' and - dist_type == 'max_dist') or (cur_backend == 'nmslib' and - dist_type == 'minkowski'): - self.assertRaises(ValueError, graphs.NNGraph, Xin, - NNtype='knn', backend=cur_backend, - dist_type=dist_type) - self.assertRaises(ValueError, graphs.NNGraph, Xin, - NNtype='radius', backend=cur_backend, - dist_type=dist_type) + features = np.random.RandomState(42).normal(size=(n_vertices, 3)) + metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib', + 'flann'] + order = 3 # for minkowski, FLANN only accepts integer orders + + for backend in backends: + for metric in metrics: + if ((backend == 'flann' and metric == 'max_dist') or + (backend == 'nmslib' and metric == 'minkowski')): + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='knn', backend=backend, + metric=metric) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='radius', backend=backend, + metric=metric) else: - if cur_backend == 'nmslib': - self.assertRaises(ValueError, graphs.NNGraph, Xin, - NNtype='radius', backend=cur_backend, - dist_type=dist_type, order=order) + if backend == 'nmslib': + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='radius', backend=backend, + metric=metric, order=order) else: - graphs.NNGraph(Xin, NNtype='radius', - backend=cur_backend, - dist_type=dist_type, order=order) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type, order=order) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type, order=order, + graphs.NNGraph(features, kind='radius', + backend=backend, + metric=metric, order=order) + graphs.NNGraph(features, kind='knn', + backend=backend, + metric=metric, order=order) + graphs.NNGraph(features, kind='knn', + backend=backend, + metric=metric, order=order, center=False) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type, order=order, + graphs.NNGraph(features, kind='knn', + backend=backend, + metric=metric, order=order, rescale=False) - graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, - dist_type=dist_type, order=order, + graphs.NNGraph(features, kind='knn', + backend=backend, + metric=metric, order=order, rescale=False, center=False) - self.assertRaises(ValueError, graphs.NNGraph, Xin, - NNtype='badtype', backend=cur_backend, - dist_type=dist_type) - self.assertRaises(ValueError, graphs.NNGraph, Xin, - NNtype='knn', backend='badtype', - dist_type=dist_type) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='invalid', backend=backend, + metric=metric) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='knn', backend='invalid', + metric=metric) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='knn', k=n_vertices+1) def test_nngraph_consistency(self): - Xin = np.arange(90).reshape(30, 3) - dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'nmslib'] - if os.name != 'nt': - backends.append('flann') - num_neighbors=4 - epsilon=0.1 - - # use pdist as ground truth - G = graphs.NNGraph(Xin, NNtype='knn', + features = np.arange(90).reshape(30, 3) + metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann', 'nmslib'] + num_neighbors = 4 + radius = 0.1 + + G = graphs.NNGraph(features, kind='knn', backend='scipy-pdist', k=num_neighbors) - for cur_backend in backends: - for dist_type in dist_types: - if cur_backend == 'flann' and dist_type == 'max_dist': + for backend in backends: + for metric in metrics: + if backend == 'flann' and metric == 'max_dist': continue - if cur_backend == 'nmslib' and dist_type == 'minkowski': + if backend == 'nmslib' and metric == 'minkowski': continue - #print("backend={} dist={}".format(cur_backend, dist_type)) - Gt = graphs.NNGraph(Xin, NNtype='knn', - backend=cur_backend, k=num_neighbors) + Gt = graphs.NNGraph(features, kind='knn', + backend=backend, k=num_neighbors) d = sparse.linalg.norm(G.W - Gt.W) - self.assertTrue(d < 0.01, 'Graphs (knn {}/{}) are not identical error={}'.format(cur_backend, dist_type, d)) - - G = graphs.NNGraph(Xin, NNtype='radius', - backend='scipy-pdist', epsilon=epsilon) - for cur_backend in backends: - for dist_type in dist_types: - if cur_backend == 'flann' and dist_type == 'max_dist': + self.assertTrue(d < 0.01, 'Graphs (knn {}/{}) are not identical error={}'.format(backend, metric, d)) + + G = graphs.NNGraph(features, kind='radius', + backend='scipy-pdist', radius=radius) + for backend in backends: + for metric in metrics: + if backend == 'flann' and metric == 'max_dist': continue - if cur_backend == 'nmslib': #unsupported + if backend == 'nmslib': continue - #print("backend={} dist={}".format(cur_backend, dist_type)) - Gt = graphs.NNGraph(Xin, NNtype='radius', - backend=cur_backend, epsilon=epsilon) + Gt = graphs.NNGraph(features, kind='radius', + backend=backend, radius=radius) d = sparse.linalg.norm(G.W - Gt.W, ord=1) - self.assertTrue(d < 0.01, - 'Graphs (radius {}/{}) are not identical error={}'.format(cur_backend, dist_type, d)) - + self.assertTrue(d < 0.01, + 'Graphs (radius {}/{}) are not identical error={}'.format(backend, metric, d)) + def test_bunny(self): graphs.Bunny() diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 0aa29d44..a28ae0c3 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -50,8 +50,8 @@ def test_plot_graphs(self): # Classes who require parameters. if classname == 'NNGraph': - Xin = np.arange(90).reshape(30, 3) - Gs.append(Graph(Xin)) + features = np.random.RandomState(42).normal(size=(30, 3)) + Gs.append(Graph(features)) elif classname in ['ImgPatches', 'Grid2dImgPatches']: Gs.append(Graph(img=self._img, patch_shape=(3, 3))) else: diff --git a/setup.py b/setup.py index b49ade95..6bdd0b57 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,6 @@ 'scikit-image', # Approximate nearest neighbors for kNN graphs. 'cyflann', - 'pybind11', 'nmslib', # Convex optimization on graph. 'pyunlocbox', @@ -69,7 +68,7 @@ # Dependencies to build and upload packages. 'pkg': [ 'wheel', - 'twine' + 'twine', ], }, license="BSD", From 9b5d8c0040d4285b71a804eded4498d8edd5c4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Feb 2019 01:11:24 +0100 Subject: [PATCH 030/365] python 2.7 doesn't support keyword-only args --- pygsp/graphs/nngraphs/nngraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 85b7ab43..eaa2ff1b 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -284,7 +284,7 @@ class NNGraph(Graph): """ - def __init__(self, features, *, center=True, rescale=True, + def __init__(self, features, center=True, rescale=True, metric='euclidean', order=0, kind='knn', k=10, radius=0.01, kernel_width=None, From 720646e798e399edb75a160796d17a8f20f86225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 10:28:04 +0100 Subject: [PATCH 031/365] simplify test_nngraph (PR #21) --- pygsp/tests/test_graphs.py | 57 ++++++++++++++------------------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c516a8a7..c018ad02 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -348,6 +348,7 @@ def test_set_coordinates(self): self.assertRaises(ValueError, G.set_coordinates, 'invalid') def test_nngraph(self, n_vertices=30): + """Test all the combinations of metric, kind, backend.""" features = np.random.RandomState(42).normal(size=(n_vertices, 3)) metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib', @@ -356,46 +357,30 @@ def test_nngraph(self, n_vertices=30): for backend in backends: for metric in metrics: - if ((backend == 'flann' and metric == 'max_dist') or - (backend == 'nmslib' and metric == 'minkowski')): - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', backend=backend, - metric=metric) - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='radius', backend=backend, - metric=metric) - else: - if backend == 'nmslib': - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='radius', backend=backend, - metric=metric, order=order) + for kind in ['knn', 'radius']: + params = dict(features=features, metric=metric, + order=order, kind=kind, backend=backend) + # Unsupported combinations. + if backend == 'flann' and metric == 'max_dist': + self.assertRaises(ValueError, graphs.NNGraph, **params) + elif backend == 'nmslib' and metric == 'minkowski': + self.assertRaises(ValueError, graphs.NNGraph, **params) + elif backend == 'nmslib' and kind == 'radius': + self.assertRaises(ValueError, graphs.NNGraph, **params) else: - graphs.NNGraph(features, kind='radius', - backend=backend, - metric=metric, order=order) - graphs.NNGraph(features, kind='knn', - backend=backend, - metric=metric, order=order) - graphs.NNGraph(features, kind='knn', - backend=backend, - metric=metric, order=order, - center=False) - graphs.NNGraph(features, kind='knn', - backend=backend, - metric=metric, order=order, - rescale=False) - graphs.NNGraph(features, kind='knn', - backend=backend, - metric=metric, order=order, - rescale=False, center=False) + graphs.NNGraph(**params, center=False) + graphs.NNGraph(**params, rescale=False) + graphs.NNGraph(**params, center=False, rescale=False) + + # Invalid parameters. + self.assertRaises(ValueError, graphs.NNGraph, features, + metric='invalid') self.assertRaises(ValueError, graphs.NNGraph, features, - kind='invalid', backend=backend, - metric=metric) + kind='invalid') self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', backend='invalid', - metric=metric) + backend='invalid') self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', k=n_vertices+1) + kind='knn', k=n_vertices+1) def test_nngraph_consistency(self): features = np.arange(90).reshape(30, 3) From 172d83f4d39724b7666ffffe45e45f1e81fba34b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 11:13:22 +0100 Subject: [PATCH 032/365] python 2.7 doesn't support dict unpacking --- pygsp/tests/test_graphs.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c018ad02..ff8c5797 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -358,19 +358,26 @@ def test_nngraph(self, n_vertices=30): for backend in backends: for metric in metrics: for kind in ['knn', 'radius']: - params = dict(features=features, metric=metric, - order=order, kind=kind, backend=backend) # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': - self.assertRaises(ValueError, graphs.NNGraph, **params) + self.assertRaises(ValueError, graphs.NNGraph, features, + metric=metric, backend=backend) elif backend == 'nmslib' and metric == 'minkowski': - self.assertRaises(ValueError, graphs.NNGraph, **params) + self.assertRaises(ValueError, graphs.NNGraph, features, + metric=metric, backend=backend) elif backend == 'nmslib' and kind == 'radius': - self.assertRaises(ValueError, graphs.NNGraph, **params) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind=kind, backend=backend) else: - graphs.NNGraph(**params, center=False) - graphs.NNGraph(**params, rescale=False) - graphs.NNGraph(**params, center=False, rescale=False) + graphs.NNGraph(features, metric=metric, order=order, + kind=kind, backend=backend, + center=False) + graphs.NNGraph(features, metric=metric, order=order, + kind=kind, backend=backend, + rescale=False) + graphs.NNGraph(features, metric=metric, order=order, + kind=kind, backend=backend, + center=False, rescale=False) # Invalid parameters. self.assertRaises(ValueError, graphs.NNGraph, features, From be16da94b3dfe4c4293bf94470fec283f742a2b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 11:30:14 +0100 Subject: [PATCH 033/365] correct number of edges (PR #21) --- pygsp/graphs/nngraphs/nngraph.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index eaa2ff1b..18d9d679 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -344,22 +344,23 @@ def __init__(self, features, center=True, rescale=True, # Discard distance to self. self.kernel_width = np.mean([np.mean(d[1:]) for d in D]) - countV = list(map(len, NN)) - count = sum(countV) - spi = np.zeros((count)) - spj = np.zeros((count)) - spv = np.zeros((count)) + n_edges = [len(x) - 1 for x in NN] # remove distance to self + value = np.empty(sum(n_edges), dtype=np.float) + row = np.empty_like(value, dtype=np.int) + col = np.empty_like(value, dtype=np.int) start = 0 - for i in range(N): - length = countV[i] - 1 - distance = np.power(D[i][1:], 2) - spi[start:start + length] = np.kron(np.ones((length)), i) - spj[start:start + length] = NN[i][1:] - spv[start:start + length] = np.exp(-distance / self.kernel_width) - start = start + length - - W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) + for vertex in range(N): + if kind == 'knn': + assert n_edges[vertex] == k + end = start + n_edges[vertex] + distance = np.power(D[vertex][1:], 2) + value[start:end] = np.exp(-distance / self.kernel_width) + row[start:end] = np.full(n_edges[vertex], vertex) + col[start:end] = NN[vertex][1:] + start = end + + W = sparse.csc_matrix((value, (row, col)), shape=(N, N)) # Enforce symmetry. May have been broken by k-NN. Checking symmetry # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. From a8798180d7ebd6f16ab16de681d9ef08e44b85c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 11:32:50 +0100 Subject: [PATCH 034/365] order=3 by default (order=0 is not supported by all backends) --- pygsp/graphs/nngraphs/nngraph.py | 2 +- pygsp/tests/test_graphs.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 18d9d679..592d84e5 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -285,7 +285,7 @@ class NNGraph(Graph): """ def __init__(self, features, center=True, rescale=True, - metric='euclidean', order=0, + metric='euclidean', order=3, kind='knn', k=10, radius=0.01, kernel_width=None, backend='scipy-ckdtree', diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ff8c5797..072bc8b4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -353,7 +353,6 @@ def test_nngraph(self, n_vertices=30): metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib', 'flann'] - order = 3 # for minkowski, FLANN only accepts integer orders for backend in backends: for metric in metrics: @@ -369,13 +368,13 @@ def test_nngraph(self, n_vertices=30): self.assertRaises(ValueError, graphs.NNGraph, features, kind=kind, backend=backend) else: - graphs.NNGraph(features, metric=metric, order=order, - kind=kind, backend=backend, + graphs.NNGraph(features, metric=metric, kind=kind, + backend=backend, center=False) - graphs.NNGraph(features, metric=metric, order=order, + graphs.NNGraph(features, metric=metric, kind=kind, backend=backend, rescale=False) - graphs.NNGraph(features, metric=metric, order=order, + graphs.NNGraph(features, metric=metric, kind=kind, backend=backend, center=False, rescale=False) From 719d397034a5b92824e4a831a280b193b2708ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 12:02:02 +0100 Subject: [PATCH 035/365] avoid deprecation warning --- pygsp/graphs/nngraphs/nngraph.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 592d84e5..e0bfc4d3 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -123,9 +123,13 @@ def _radius_sp_ckdtree(features, radius, metric, order): def _knn_sp_pdist(features, num_neighbors, metric, order): - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric], - p=order) + if metric == 'minkowski': + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric], + p=order) + else: + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric]) pd = spatial.distance.squareform(p) pds = np.sort(pd)[:, :num_neighbors+1] pdi = pd.argsort()[:, :num_neighbors+1] @@ -148,10 +152,14 @@ def _knn_nmslib(features, num_neighbors, metric, _): def _radius_sp_pdist(features, radius, metric, order): - n_vertices, _ = np.shape(features) - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric], - p=order) + n_vertices, _ = features.shape + if metric == 'minkowski': + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric], + p=order) + else: + p = spatial.distance.pdist(features, + metric=_metrics['scipy-pdist'][metric]) pd = spatial.distance.squareform(p) pdf = pd < radius D = [] @@ -162,7 +170,6 @@ def _radius_sp_pdist(features, radius, metric, order): # use the same conventions as in scipy.distance.kdtree NN.append(d[0:len(v)]) D.append(np.sort(v)) - return NN, D From eb2ab0b94728ccfcf4e8f76307f21bdeef1fb82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 12:46:10 +0100 Subject: [PATCH 036/365] deal with empty neighborhood --- pygsp/graphs/nngraphs/nngraph.py | 19 +++++++++++++++++-- pygsp/tests/test_graphs.py | 2 ++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index e0bfc4d3..5550ff7b 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -332,6 +332,9 @@ def __init__(self, features, center=True, rescale=True, raise ValueError('The number of neighbors (k={}) must be smaller ' 'than the number of vertices ({}).'.format(k, N)) + if kind == 'radius' and radius <= 0: + raise ValueError('The radius must be greater than 0.') + if center: features = center_features(features) @@ -348,14 +351,26 @@ def __init__(self, features, center=True, rescale=True, NN, D = _nn_functions[kind][backend](features, radius, metric, order) if self.kernel_width is None: - # Discard distance to self. - self.kernel_width = np.mean([np.mean(d[1:]) for d in D]) + # Discard distance to self and deal with disconnected vertices. + means = [] + for distance in D: + if len(distance) > 1: + means.append(np.mean(distance[1:])) + self.kernel_width = np.mean(means) if len(means) > 0 else 0 n_edges = [len(x) - 1 for x in NN] # remove distance to self value = np.empty(sum(n_edges), dtype=np.float) row = np.empty_like(value, dtype=np.int) col = np.empty_like(value, dtype=np.int) + if kind == 'radius': + n_disconnected = np.sum(np.asarray(n_edges) == 0) + if n_disconnected > 0: + logger = utils.build_logger(__name__) + logger.warning('{} vertices (out of {}) are disconnected. ' + 'Consider increasing the radius or setting ' + 'kind=knn.'.format(n_disconnected, N)) + start = 0 for vertex in range(N): if kind == 'knn': diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 072bc8b4..0b55aa44 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -387,6 +387,8 @@ def test_nngraph(self, n_vertices=30): backend='invalid') self.assertRaises(ValueError, graphs.NNGraph, features, kind='knn', k=n_vertices+1) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='radius', radius=0) def test_nngraph_consistency(self): features = np.arange(90).reshape(30, 3) From b2bfb51c2840489b25aa323bd358a82d80dd3e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 15:28:59 +0100 Subject: [PATCH 037/365] nngraph: don't store features --- pygsp/graphs/nngraphs/imgpatches.py | 10 +++++----- pygsp/graphs/nngraphs/nngraph.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 95ce9149..0e09f9de 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -36,7 +36,7 @@ class ImgPatches(NNGraph): >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::64, ::64]) >>> G = graphs.ImgPatches(img, patch_shape=(3, 3)) - >>> N, d = G.features.shape + >>> N, d = G.patches.shape >>> print('{} nodes ({} x {} pixels)'.format(N, *img.shape)) 64 nodes (8 x 8 pixels) >>> print('{} features per node'.format(d)) @@ -85,16 +85,16 @@ def __init__(self, img, patch_shape=(3, 3), **kwargs): # Alternative: sklearn.feature_extraction.image.extract_patches_2d. # sklearn has much less dependencies than skimage. try: - import skimage + from skimage.util import view_as_windows except Exception as e: raise ImportError('Cannot import skimage, which is needed to ' 'extract patches. Try to install it with ' 'pip (or conda) install scikit-image. ' 'Original exception: {}'.format(e)) - patches = skimage.util.view_as_windows(img, window_shape=window_shape) - patches = patches.reshape((h * w, r * c * d)) + self.patches = view_as_windows(img, window_shape) + self.patches = self.patches.reshape((h * w, r * c * d)) - super(ImgPatches, self).__init__(patches, **kwargs) + super(ImgPatches, self).__init__(self.patches, **kwargs) def _get_extra_repr(self): attrs = dict(patch_shape=self.patch_shape) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 5550ff7b..4b2f3c7a 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -298,7 +298,7 @@ def __init__(self, features, center=True, rescale=True, backend='scipy-ckdtree', **kwargs): - self.features = features # stored in coords, but scaled and centered + # features is stored in coords, potentially standardized self.center = center self.rescale = rescale self.metric = metric From e1879eec5dbefa707bd7fb0bb388d613e4abf986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 15:34:38 +0100 Subject: [PATCH 038/365] nngraph: further cleanup --- pygsp/graphs/nngraphs/nngraph.py | 91 +++++++++++++++----------------- 1 file changed, 43 insertions(+), 48 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 4b2f3c7a..8a89d220 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -210,8 +210,7 @@ def _radius_flann(features, radius, metric, order): def center_features(features): - n_vertices, _ = features.shape - return features - np.kron(np.ones((n_vertices, 1)), np.mean(features, 0)) + return features - np.mean(features, axis=0) def rescale_features(features): @@ -298,67 +297,52 @@ def __init__(self, features, center=True, rescale=True, backend='scipy-ckdtree', **kwargs): - # features is stored in coords, potentially standardized - self.center = center - self.rescale = rescale - self.metric = metric - self.order = order - self.kind = kind - self.k = k - self.radius = radius - self.kernel_width = kernel_width - self.backend = backend - - N, d = np.shape(features) + n_vertices, _ = features.shape + if _metrics['scipy-pdist'].get(metric) is None: + raise ValueError('Invalid metric "{}".'.format(metric)) if _nn_functions.get(kind) is None: raise ValueError('Invalid kind "{}".'.format(kind)) - if backend not in _metrics.keys(): raise ValueError('Invalid backend "{}".'.format(backend)) - - if _metrics['scipy-pdist'].get(metric) is None: - raise ValueError('Invalid metric "{}".'.format(metric)) - - if _nn_functions[kind].get(backend) is None: - raise ValueError('{} does not support kind "{}".'.format( - backend, kind)) - if _metrics[backend].get(metric) is None: - raise ValueError('{} does not support the {} metric.'.format( + raise ValueError('{} does not support metric="{}".'.format( backend, metric)) - - if kind == 'knn' and k >= N: + if _nn_functions[kind].get(backend) is None: + raise ValueError('{} does not support kind="{}".'.format( + backend, kind)) + if kind == 'knn' and k >= n_vertices: raise ValueError('The number of neighbors (k={}) must be smaller ' - 'than the number of vertices ({}).'.format(k, N)) - - if kind == 'radius' and radius <= 0: + 'than the number of vertices ({}).'.format( + k, n_vertices)) + if kind == 'radius' and radius is not None and radius <= 0: raise ValueError('The radius must be greater than 0.') if center: features = center_features(features) - if rescale: features = rescale_features(features) + nn_function = _nn_functions[kind][backend] if kind == 'knn': - NN, D = _nn_functions[kind][backend](features, k, metric, order) - if self.kernel_width is None: - # Discard distance to self. - self.kernel_width = np.mean(D[:, 1:]) - + neighbors, distances = nn_function(features, k, metric, order) elif kind == 'radius': - NN, D = _nn_functions[kind][backend](features, radius, - metric, order) - if self.kernel_width is None: - # Discard distance to self and deal with disconnected vertices. + neighbors, distances = nn_function(features, radius, metric, order) + + if kernel_width is None: + # Discard distance to self and deal with disconnected vertices. + if kind == 'knn': + kernel_width = np.mean(distances[:, 1:]) + elif kind == 'radius': means = [] - for distance in D: + for distance in distances: if len(distance) > 1: means.append(np.mean(distance[1:])) - self.kernel_width = np.mean(means) if len(means) > 0 else 0 + kernel_width = np.mean(means) if len(means) > 0 else 0 + # Alternative: kernel_width = radius / 2 + # Alternative: kernel_width = radius / np.log(2) - n_edges = [len(x) - 1 for x in NN] # remove distance to self + n_edges = [len(n) - 1 for n in neighbors] # remove distance to self value = np.empty(sum(n_edges), dtype=np.float) row = np.empty_like(value, dtype=np.int) col = np.empty_like(value, dtype=np.int) @@ -369,25 +353,36 @@ def __init__(self, features, center=True, rescale=True, logger = utils.build_logger(__name__) logger.warning('{} vertices (out of {}) are disconnected. ' 'Consider increasing the radius or setting ' - 'kind=knn.'.format(n_disconnected, N)) + 'kind=knn.'.format(n_disconnected, n_vertices)) start = 0 - for vertex in range(N): + for vertex in range(n_vertices): if kind == 'knn': assert n_edges[vertex] == k end = start + n_edges[vertex] - distance = np.power(D[vertex][1:], 2) - value[start:end] = np.exp(-distance / self.kernel_width) + distance = np.power(distances[vertex][1:], 2) + value[start:end] = np.exp(-distance / kernel_width) row[start:end] = np.full(n_edges[vertex], vertex) - col[start:end] = NN[vertex][1:] + col[start:end] = neighbors[vertex][1:] start = end - W = sparse.csc_matrix((value, (row, col)), shape=(N, N)) + W = sparse.csr_matrix((value, (row, col)), (n_vertices, n_vertices)) # Enforce symmetry. May have been broken by k-NN. Checking symmetry # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. W = utils.symmetrize(W, method='average') + # features is stored in coords, potentially standardized + self.center = center + self.rescale = rescale + self.metric = metric + self.order = order + self.kind = kind + self.radius = radius + self.kernel_width = kernel_width + self.k = k + self.backend = backend + super(NNGraph, self).__init__(W=W, coords=features, **kwargs) def _get_extra_repr(self): From bf7427fa96dd522f26274ebd9569c6ec631f7a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Feb 2019 16:05:12 +0100 Subject: [PATCH 039/365] nngraph: standardize instead of center and rescale Rescaling was useful before the adaptive setting (to the mean of the distances) of the kernel width. It is now useless. Centering can be combined with normalizing to unit variance to standardize the data. --- pygsp/graphs/nngraphs/bunny.py | 4 +--- pygsp/graphs/nngraphs/cube.py | 4 +--- pygsp/graphs/nngraphs/nngraph.py | 35 +++++++++---------------------- pygsp/graphs/nngraphs/sensor.py | 4 +--- pygsp/graphs/nngraphs/sphere.py | 4 +--- pygsp/graphs/nngraphs/twomoons.py | 1 - pygsp/tests/test_graphs.py | 13 ++++-------- 7 files changed, 18 insertions(+), 47 deletions(-) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 21729823..cc269c10 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -34,7 +34,5 @@ def __init__(self, **kwargs): 'distance': 8, } - super(Bunny, self).__init__(data['bunny'], - center=False, rescale=False, - kind='radius', radius=0.02, + super(Bunny, self).__init__(data['bunny'], kind='radius', radius=0.02, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 85f51347..2328c2cb 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -89,9 +89,7 @@ def __init__(self, 'distance': 9, } - super(Cube, self).__init__(pts, k=10, - center=False, rescale=False, - plotting=plotting, **kwargs) + super(Cube, self).__init__(pts, k=10, plotting=plotting, **kwargs) def _get_extra_repr(self): attrs = {'radius': '{:.2f}'.format(self.radius), diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 8a89d220..0548d803 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -209,18 +209,6 @@ def _radius_flann(features, radius, metric, order): } -def center_features(features): - return features - np.mean(features, axis=0) - - -def rescale_features(features): - n_vertices, dimensionality = features.shape - bounding_radius = 0.5 * np.linalg.norm(np.amax(features, axis=0) - - np.amin(features, axis=0), 2) - scale = np.power(n_vertices, 1 / min(dimensionality, 3)) / 10 - return features * scale / bounding_radius - - class NNGraph(Graph): r"""Nearest-neighbor graph. @@ -244,10 +232,9 @@ class NNGraph(Graph): features : ndarray An `N`-by-`d` matrix, where `N` is the number of nodes in the graph and `d` is the number of features. - center : bool, optional - Whether to center the features to have zero mean. - rescale : bool, optional - Whether to scale the features so that they lie in an l2-ball. + standardize : bool, optional + Whether to rescale the features so that each feature has a mean of 0 + and standard deviation of 1 (unit variance). metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional Metric used to compute pairwise distances. order : float, optional @@ -290,7 +277,7 @@ class NNGraph(Graph): """ - def __init__(self, features, center=True, rescale=True, + def __init__(self, features, standardize=False, metric='euclidean', order=3, kind='knn', k=10, radius=0.01, kernel_width=None, @@ -318,10 +305,10 @@ def __init__(self, features, center=True, rescale=True, if kind == 'radius' and radius is not None and radius <= 0: raise ValueError('The radius must be greater than 0.') - if center: - features = center_features(features) - if rescale: - features = rescale_features(features) + if standardize: + # Don't alter the original data (users would be surprised). + features = features - np.mean(features, axis=0) + features /= np.std(features, axis=0) nn_function = _nn_functions[kind][backend] if kind == 'knn': @@ -373,8 +360,7 @@ def __init__(self, features, center=True, rescale=True, W = utils.symmetrize(W, method='average') # features is stored in coords, potentially standardized - self.center = center - self.rescale = rescale + self.standardize = standardize self.metric = metric self.order = order self.kind = kind @@ -387,8 +373,7 @@ def __init__(self, features, center=True, rescale=True, def _get_extra_repr(self): return { - 'center': self.center, - 'rescale': self.rescale, + 'standardize': self.standardize, 'metric': self.metric, 'order': self.order, 'kind': self.kind, diff --git a/pygsp/graphs/nngraphs/sensor.py b/pygsp/graphs/nngraphs/sensor.py index 5491e0c7..09764b02 100644 --- a/pygsp/graphs/nngraphs/sensor.py +++ b/pygsp/graphs/nngraphs/sensor.py @@ -75,9 +75,7 @@ def __init__(self, N=64, k=6, distributed=False, seed=None, **kwargs): coords = rs.uniform(0, 1, (N, 2)) - super(Sensor, self).__init__(coords, k=k, - rescale=False, center=False, - plotting=plotting, **kwargs) + super(Sensor, self).__init__(coords, k=k, plotting=plotting, **kwargs) def _get_extra_repr(self): return {'k': self.k, diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 2ad9c233..f323f562 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -64,9 +64,7 @@ def __init__(self, 'vertex_size': 80, } - super(Sphere, self).__init__(pts, k=10, - center=False, rescale=False, - plotting=plotting, **kwargs) + super(Sphere, self).__init__(pts, k=10, plotting=plotting, **kwargs) def _get_extra_repr(self): attrs = {'radius': '{:.2f}'.format(self.radius), diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 6da8ca95..1d7cdde1 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -97,7 +97,6 @@ def __init__(self, moontype='standard', dim=2, kernel_width=0.05, } super(TwoMoons, self).__init__(coords, kernel_width=kernel_width, k=5, - center=False, rescale=False, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 0b55aa44..25ef4077 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -368,15 +368,10 @@ def test_nngraph(self, n_vertices=30): self.assertRaises(ValueError, graphs.NNGraph, features, kind=kind, backend=backend) else: - graphs.NNGraph(features, metric=metric, kind=kind, - backend=backend, - center=False) - graphs.NNGraph(features, metric=metric, - kind=kind, backend=backend, - rescale=False) - graphs.NNGraph(features, metric=metric, - kind=kind, backend=backend, - center=False, rescale=False) + for standardize in [True, False]: + graphs.NNGraph(features, standardize=standardize, + metric=metric, kind=kind, + backend=backend) # Invalid parameters. self.assertRaises(ValueError, graphs.NNGraph, features, From ec74ed7e9e2efa8c2ae6140ed81f2bcbf2ce3f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Feb 2019 02:11:35 +0100 Subject: [PATCH 040/365] nngraph: simplify default kernel_width --- pygsp/graphs/nngraphs/nngraph.py | 33 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 0548d803..e0f775cd 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -316,23 +316,7 @@ def __init__(self, features, standardize=False, elif kind == 'radius': neighbors, distances = nn_function(features, radius, metric, order) - if kernel_width is None: - # Discard distance to self and deal with disconnected vertices. - if kind == 'knn': - kernel_width = np.mean(distances[:, 1:]) - elif kind == 'radius': - means = [] - for distance in distances: - if len(distance) > 1: - means.append(np.mean(distance[1:])) - kernel_width = np.mean(means) if len(means) > 0 else 0 - # Alternative: kernel_width = radius / 2 - # Alternative: kernel_width = radius / np.log(2) - n_edges = [len(n) - 1 for n in neighbors] # remove distance to self - value = np.empty(sum(n_edges), dtype=np.float) - row = np.empty_like(value, dtype=np.int) - col = np.empty_like(value, dtype=np.int) if kind == 'radius': n_disconnected = np.sum(np.asarray(n_edges) == 0) @@ -342,19 +326,30 @@ def __init__(self, features, standardize=False, 'Consider increasing the radius or setting ' 'kind=knn.'.format(n_disconnected, n_vertices)) + value = np.empty(sum(n_edges), dtype=np.float) + row = np.empty_like(value, dtype=np.int) + col = np.empty_like(value, dtype=np.int) start = 0 for vertex in range(n_vertices): if kind == 'knn': assert n_edges[vertex] == k end = start + n_edges[vertex] - distance = np.power(distances[vertex][1:], 2) - value[start:end] = np.exp(-distance / kernel_width) + value[start:end] = distances[vertex][1:] row[start:end] = np.full(n_edges[vertex], vertex) col[start:end] = neighbors[vertex][1:] start = end - W = sparse.csr_matrix((value, (row, col)), (n_vertices, n_vertices)) + if kernel_width is None: + kernel_width = np.mean(W.data) if W.nnz > 0 else np.nan + # Alternative: kernel_width = radius / 2 or radius / np.log(2). + # Users can easily do the above. + + def kernel(distance, width): + return np.exp(-distance**2 / width) + + W.data = kernel(W.data, kernel_width) + # Enforce symmetry. May have been broken by k-NN. Checking symmetry # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. W = utils.symmetrize(W, method='average') From c1e1148a8ee713f005a255ed649630aac4b95666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Feb 2019 02:51:31 +0100 Subject: [PATCH 041/365] nngraph: test empty graph --- pygsp/graphs/nngraphs/nngraph.py | 10 +++++----- pygsp/tests/test_graphs.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index e0f775cd..15ee63c9 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -298,11 +298,11 @@ def __init__(self, features, standardize=False, if _nn_functions[kind].get(backend) is None: raise ValueError('{} does not support kind="{}".'.format( backend, kind)) - if kind == 'knn' and k >= n_vertices: - raise ValueError('The number of neighbors (k={}) must be smaller ' - 'than the number of vertices ({}).'.format( - k, n_vertices)) - if kind == 'radius' and radius is not None and radius <= 0: + if not (1 <= k < n_vertices): + raise ValueError('The number of neighbors (k={}) must be greater ' + 'than 0 and smaller than the number of vertices ' + '({}).'.format(k, n_vertices)) + if (radius is not None) and (radius <= 0): raise ValueError('The radius must be greater than 0.') if standardize: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 25ef4077..42e1235c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -381,10 +381,21 @@ def test_nngraph(self, n_vertices=30): self.assertRaises(ValueError, graphs.NNGraph, features, backend='invalid') self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', k=n_vertices+1) + kind='knn', k=0) + self.assertRaises(ValueError, graphs.NNGraph, features, + kind='knn', k=n_vertices) self.assertRaises(ValueError, graphs.NNGraph, features, kind='radius', radius=0) + # Empty graph. + for backend in backends: + if backend == 'nmslib': + continue + with self.assertLogs(level='WARNING'): + graph = graphs.NNGraph(features, kind='radius', radius=1e-9, + backend=backend) + self.assertEqual(graph.n_edges, 0) + def test_nngraph_consistency(self): features = np.arange(90).reshape(30, 3) metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] From 57ce98c52ef8519627d0192c209239c89a13237c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Feb 2019 03:10:09 +0100 Subject: [PATCH 042/365] no assertLogs in python 2.7 --- pygsp/tests/test_graphs.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 42e1235c..6f863bbb 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -7,7 +7,9 @@ from __future__ import division +import sys import unittest + import numpy as np import scipy.linalg from scipy import sparse @@ -388,13 +390,14 @@ def test_nngraph(self, n_vertices=30): kind='radius', radius=0) # Empty graph. - for backend in backends: - if backend == 'nmslib': - continue - with self.assertLogs(level='WARNING'): - graph = graphs.NNGraph(features, kind='radius', radius=1e-9, - backend=backend) - self.assertEqual(graph.n_edges, 0) + if sys.version_info > (3, 4): # no assertLogs in python 2.7 + for backend in backends: + if backend == 'nmslib': + continue + with self.assertLogs(level='WARNING'): + graph = graphs.NNGraph(features, kind='radius', + radius=1e-9, backend=backend) + self.assertEqual(graph.n_edges, 0) def test_nngraph_consistency(self): features = np.arange(90).reshape(30, 3) From 695272b1b73e2f55b0f31fc351121d82470af576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Feb 2019 03:03:14 +0100 Subject: [PATCH 043/365] nngraph: fix symmetrization Symmetrizing with the average was setting the distance between v1 and v2 as the average between 0 and the true distance if v1 was the k-NN of v2 but v2 was not in the k-NN of v1. Moreover, symmetrizing before taking the mean assures that every distance is counted twice (only some would be counted twice otherwise). --- doc/tutorials/optimization.rst | 4 ++-- pygsp/filters/filter.py | 4 ++-- pygsp/graphs/fourier.py | 2 +- pygsp/graphs/nngraphs/nngraph.py | 8 ++++---- pygsp/tests/test_filters.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index 7127a9c5..dd6d91ad 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -85,7 +85,7 @@ We start with the graph TV regularization. We will use the :class:`pyunlocbox.so >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 2.250584e+02 + objective function f(sol) = 2.138668e+02 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob1['sol']) @@ -107,7 +107,7 @@ This figure shows the label signal recovered by graph total variation regulariza >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 6.504290e+01 + objective function f(sol) = 7.413918e+01 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob2['sol']) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 8fbf44d7..56d5508d 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -254,7 +254,7 @@ def filter(self, s, method='chebyshev', order=30): >>> _ = G.plot(s1, ax=axes[0]) >>> _ = G.plot(s2, ax=axes[1]) >>> print('{:.5f}'.format(np.linalg.norm(s1 - s2))) - 0.26808 + 0.28049 Perfect reconstruction with Itersine, a tight frame: @@ -449,7 +449,7 @@ def estimate_frame_bounds(self, x=None): A=1.708, B=2.359 >>> A, B = g.estimate_frame_bounds(G.e) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.723, B=2.359 + A=1.712, B=2.348 The frame bounds can be seen in the plot of the filter bank as the minimum and maximum of their squared sum (the black curve): diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 80f55769..286b3252 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -78,7 +78,7 @@ def coherence(self): >>> graph.compute_fourier_basis() >>> minimum = 1 / np.sqrt(graph.n_vertices) >>> print('{:.2f} in [{:.2f}, 1]'.format(graph.coherence, minimum)) - 0.88 in [0.12, 1] + 0.93 in [0.12, 1] >>> >>> # Plot the most localized eigenvector. >>> import matplotlib.pyplot as plt diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 15ee63c9..225f7e6c 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -340,6 +340,10 @@ def __init__(self, features, standardize=False, start = end W = sparse.csr_matrix((value, (row, col)), (n_vertices, n_vertices)) + # Enforce symmetry. May have been broken by k-NN. Checking symmetry + # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. + W = utils.symmetrize(W, method='fill') + if kernel_width is None: kernel_width = np.mean(W.data) if W.nnz > 0 else np.nan # Alternative: kernel_width = radius / 2 or radius / np.log(2). @@ -350,10 +354,6 @@ def kernel(distance, width): W.data = kernel(W.data, kernel_width) - # Enforce symmetry. May have been broken by k-NN. Checking symmetry - # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. - W = utils.symmetrize(W, method='average') - # features is stored in coords, potentially standardized self.standardize = standardize self.metric = metric diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 0bf066f5..fe093da0 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -228,7 +228,7 @@ def test_modulation_gabor(self): f2 = filters.Gabor(self._G, f) s1 = f1.filter(self._signal) s2 = f2.filter(self._signal) - np.testing.assert_allclose(s1, -s2, atol=1e-5) + np.testing.assert_allclose(s1, s2, atol=1e-5) def test_halfcosine(self): f = filters.HalfCosine(self._G, Nf=4) From 505e456121fe82d6774727413a764e9d3733366f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Feb 2019 03:15:15 +0100 Subject: [PATCH 044/365] nngraph: fix radius cKDTree (PR #21) --- pygsp/graphs/nngraphs/nngraph.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 225f7e6c..3a5a4d2a 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -107,19 +107,16 @@ def _radius_sp_kdtree(features, radius, metric, order): def _radius_sp_ckdtree(features, radius, metric, order): p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] n_vertices, _ = features.shape - kdt = spatial.cKDTree(features) - nn = kdt.query_ball_point(features, r=radius, p=p, n_jobs=-1) - D = [] - NN = [] - for k in range(n_vertices): - x = np.tile(features[k, :], (len(nn[k]), 1)) - d = np.linalg.norm(x - features[nn[k], :], - ord=_metrics['scipy-ckdtree'][metric], - axis=1) - nidx = d.argsort() - NN.append(np.take(nn[k], nidx)) - D.append(np.sort(d)) - return NN, D + tree = spatial.cKDTree(features) + D, NN = tree.query(features, k=n_vertices, distance_upper_bound=radius, + p=p, n_jobs=-1) + distances = [] + neighbors = [] + for d, n in zip(D, NN): + mask = (d != np.inf) + distances.append(d[mask]) + neighbors.append(n[mask]) + return neighbors, distances def _knn_sp_pdist(features, num_neighbors, metric, order): From 204ad1976ad532cdbe6e5aabb2c487840b98ebdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 14:48:59 +0100 Subject: [PATCH 045/365] compact code --- pygsp/tests/test_graphs.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6f863bbb..5c97a2f6 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -351,7 +351,8 @@ def test_set_coordinates(self): def test_nngraph(self, n_vertices=30): """Test all the combinations of metric, kind, backend.""" - features = np.random.RandomState(42).normal(size=(n_vertices, 3)) + Graph = graphs.NNGraph + data = np.random.RandomState(42).normal(size=(n_vertices, 3)) metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib', 'flann'] @@ -361,42 +362,35 @@ def test_nngraph(self, n_vertices=30): for kind in ['knn', 'radius']: # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': - self.assertRaises(ValueError, graphs.NNGraph, features, + self.assertRaises(ValueError, Graph, data, metric=metric, backend=backend) elif backend == 'nmslib' and metric == 'minkowski': - self.assertRaises(ValueError, graphs.NNGraph, features, + self.assertRaises(ValueError, Graph, data, metric=metric, backend=backend) elif backend == 'nmslib' and kind == 'radius': - self.assertRaises(ValueError, graphs.NNGraph, features, + self.assertRaises(ValueError, Graph, data, kind=kind, backend=backend) else: for standardize in [True, False]: - graphs.NNGraph(features, standardize=standardize, - metric=metric, kind=kind, - backend=backend) + Graph(data, standardize=standardize, metric=metric, + kind=kind, backend=backend) # Invalid parameters. - self.assertRaises(ValueError, graphs.NNGraph, features, - metric='invalid') - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='invalid') - self.assertRaises(ValueError, graphs.NNGraph, features, - backend='invalid') - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', k=0) - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='knn', k=n_vertices) - self.assertRaises(ValueError, graphs.NNGraph, features, - kind='radius', radius=0) + self.assertRaises(ValueError, Graph, data, metric='invalid') + self.assertRaises(ValueError, Graph, data, kind='invalid') + self.assertRaises(ValueError, Graph, data, backend='invalid') + self.assertRaises(ValueError, Graph, data, kind='knn', k=0) + self.assertRaises(ValueError, Graph, data, kind='knn', k=n_vertices) + self.assertRaises(ValueError, Graph, data, kind='radius', radius=0) # Empty graph. if sys.version_info > (3, 4): # no assertLogs in python 2.7 for backend in backends: if backend == 'nmslib': - continue + continue # nmslib doesn't support radius with self.assertLogs(level='WARNING'): - graph = graphs.NNGraph(features, kind='radius', - radius=1e-9, backend=backend) + graph = Graph(data, kind='radius', radius=1e-9, + backend=backend) self.assertEqual(graph.n_edges, 0) def test_nngraph_consistency(self): From 2b253375de2e37490624e17539da1cdc9c8eb8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 15:00:08 +0100 Subject: [PATCH 046/365] NNGraph: allow user to pass parameters to backends --- pygsp/graphs/nngraphs/nngraph.py | 96 +++++++++++++++++++------------- pygsp/tests/test_graphs.py | 13 +++++ 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3a5a4d2a..0bbeea43 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -68,48 +68,53 @@ def _import_nmslib(): return nms -def _knn_sp_kdtree(features, num_neighbors, metric, order): +def _knn_sp_kdtree(features, k, metric, order, params): p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] - kdt = spatial.KDTree(features) - D, NN = kdt.query(features, k=(num_neighbors + 1), p=p) - return NN, D + eps = params.pop('eps', 0) + tree = spatial.KDTree(features, **params) + distances, neighbors = tree.query(features, k=k+1, p=p, eps=eps) + return neighbors, distances -def _knn_sp_ckdtree(features, num_neighbors, metric, order): +def _knn_sp_ckdtree(features, k, metric, order, params): p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] - kdt = spatial.cKDTree(features) - D, NN = kdt.query(features, k=(num_neighbors + 1), p=p, n_jobs=-1) - return NN, D + eps = params.pop('eps', 0) + kdt = spatial.cKDTree(features, **params) + distances, neighbors = kdt.query(features, k=k+1, p=p, eps=eps, n_jobs=-1) + return neighbors, distances -def _knn_flann(features, num_neighbors, metric, order): +def _knn_flann(features, k, metric, order, params): cfl = _import_cfl() cfl.set_distance_type(metric, order=order) - c = cfl.FLANNIndex(algorithm='kdtree') - c.build_index(features) + index = cfl.FLANNIndex() + index.build_index(features, **params) # Default FLANN parameters (I tried changing the algorithm and # testing performance on huge matrices, but the default one # seems to work best). - NN, D = c.nn_index(features, num_neighbors + 1) - c.free_index() + neighbors, distances = index.nn_index(features, k+1) + index.free_index() if metric == 'euclidean': # flann returns squared distances - return NN, np.sqrt(D) - return NN, D + np.sqrt(distances, out=distances) + return neighbors, distances -def _radius_sp_kdtree(features, radius, metric, order): +def _radius_sp_kdtree(features, radius, metric, order, params): p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] - kdt = spatial.KDTree(features) - D, NN = kdt.query(features, k=None, distance_upper_bound=radius, p=p) - return NN, D + eps = params.pop('eps', 0) + tree = spatial.KDTree(features, **params) + distances, neighbors = tree.query(features, p=p, eps=eps, k=None, + distance_upper_bound=radius) + return neighbors, distances -def _radius_sp_ckdtree(features, radius, metric, order): +def _radius_sp_ckdtree(features, radius, metric, order, params): p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] n_vertices, _ = features.shape - tree = spatial.cKDTree(features) + eps = params.pop('eps', 0) + tree = spatial.cKDTree(features, **params) D, NN = tree.query(features, k=n_vertices, distance_upper_bound=radius, - p=p, n_jobs=-1) + p=p, eps=eps, n_jobs=-1) distances = [] neighbors = [] for d, n in zip(D, NN): @@ -119,7 +124,7 @@ def _radius_sp_ckdtree(features, radius, metric, order): return neighbors, distances -def _knn_sp_pdist(features, num_neighbors, metric, order): +def _knn_sp_pdist(features, num_neighbors, metric, order, _): if metric == 'minkowski': p = spatial.distance.pdist(features, metric=_metrics['scipy-pdist'][metric], @@ -133,22 +138,26 @@ def _knn_sp_pdist(features, num_neighbors, metric, order): return pdi, pds -def _knn_nmslib(features, num_neighbors, metric, _): +def _knn_nmslib(features, num_neighbors, metric, _, params): n_vertices, _ = features.shape ncpu = multiprocessing.cpu_count() nms = _import_nmslib() - nmsidx = nms.init(space=_metrics['nmslib'][metric]) - nmsidx.addDataPointBatch(features) - nmsidx.createIndex() - q = nmsidx.knnQueryBatch(features, k=num_neighbors+1, - num_threads=int(ncpu/2)) + params_index = params.pop('index', None) + params_query = params.pop('query', None) + index = nms.init(space=_metrics['nmslib'][metric], **params) + index.addDataPointBatch(features) + index.createIndex(params_index) + if params_query is not None: + index.setQueryTimeParams(params_query) + q = index.knnQueryBatch(features, k=num_neighbors+1, + num_threads=int(ncpu/2)) nn, d = zip(*q) D = np.concatenate(d).reshape(n_vertices, num_neighbors+1) NN = np.concatenate(nn).reshape(n_vertices, num_neighbors+1) return NN, D -def _radius_sp_pdist(features, radius, metric, order): +def _radius_sp_pdist(features, radius, metric, order, _): n_vertices, _ = features.shape if metric == 'minkowski': p = spatial.distance.pdist(features, @@ -170,19 +179,19 @@ def _radius_sp_pdist(features, radius, metric, order): return NN, D -def _radius_flann(features, radius, metric, order): +def _radius_flann(features, radius, metric, order, params): n_vertices, _ = features.shape cfl = _import_cfl() cfl.set_distance_type(metric, order=order) - c = cfl.FLANNIndex(algorithm='kdtree') - c.build_index(features) + index = cfl.FLANNIndex() + index.build_index(features, **params) D = [] NN = [] for k in range(n_vertices): - nn, d = c.nn_radius(features[k, :], radius**2) + nn, d = index.nn_radius(features[k, :], radius**2) D.append(d) NN.append(nn) - c.free_index() + index.free_index() if metric == 'euclidean': # Flann returns squared distances. D = list(map(np.sqrt, D)) @@ -263,6 +272,10 @@ class NNGraph(Graph): `_. That method is an approximation. It should be the fastest in high-dimensional spaces. + kwargs : dict + Parameters to be passed to the :class:`Graph` constructor or the + backend library. + Examples -------- >>> import matplotlib.pyplot as plt @@ -283,6 +296,13 @@ def __init__(self, features, standardize=False, n_vertices, _ = features.shape + params_graph = dict() + for key in ['lap_type', 'plotting']: + try: + params_graph[key] = kwargs.pop(key) + except KeyError: + pass + if _metrics['scipy-pdist'].get(metric) is None: raise ValueError('Invalid metric "{}".'.format(metric)) if _nn_functions.get(kind) is None: @@ -309,9 +329,9 @@ def __init__(self, features, standardize=False, nn_function = _nn_functions[kind][backend] if kind == 'knn': - neighbors, distances = nn_function(features, k, metric, order) + neighbors, distances = nn_function(features, k, metric, order, kwargs) elif kind == 'radius': - neighbors, distances = nn_function(features, radius, metric, order) + neighbors, distances = nn_function(features, radius, metric, order, kwargs) n_edges = [len(n) - 1 for n in neighbors] # remove distance to self @@ -361,7 +381,7 @@ def kernel(distance, width): self.k = k self.backend = backend - super(NNGraph, self).__init__(W=W, coords=features, **kwargs) + super(NNGraph, self).__init__(W=W, coords=features, **params_graph) def _get_extra_repr(self): return { diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 5c97a2f6..c0142317 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -393,6 +393,19 @@ def test_nngraph(self, n_vertices=30): backend=backend) self.assertEqual(graph.n_edges, 0) + # Backend parameters. + Graph(data, lap_type='normalized') + Graph(data, plotting=dict(vertex_size=10)) + Graph(data, backend='flann', kind='knn', algorithm='kmeans') + Graph(data, backend='flann', kind='radius', random_seed=0) + Graph(data, backend='nmslib', method='vptree') + Graph(data, backend='nmslib', index=dict(post=2)) + Graph(data, backend='nmslib', query=dict(efSearch=10)) + for backend in ['scipy-kdtree', 'scipy-ckdtree']: + for kind in ['knn', 'radius']: + Graph(data, backend=backend, kind=kind, eps=1e-2) + Graph(data, backend=backend, kind=kind, leafsize=9) + def test_nngraph_consistency(self): features = np.arange(90).reshape(30, 3) metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] From 8cc3539d920a7a97c9a4050e8ff6eca402321642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 15:16:02 +0100 Subject: [PATCH 047/365] fix flann distances --- pygsp/graphs/nngraphs/nngraph.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 0bbeea43..bcaf7ffe 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -94,8 +94,10 @@ def _knn_flann(features, k, metric, order, params): # seems to work best). neighbors, distances = index.nn_index(features, k+1) index.free_index() - if metric == 'euclidean': # flann returns squared distances + if metric == 'euclidean': np.sqrt(distances, out=distances) + elif metric == 'minkowski': + np.power(distances, 1/order, out=distances) return neighbors, distances @@ -185,17 +187,22 @@ def _radius_flann(features, radius, metric, order, params): cfl.set_distance_type(metric, order=order) index = cfl.FLANNIndex() index.build_index(features, **params) - D = [] - NN = [] - for k in range(n_vertices): - nn, d = index.nn_radius(features[k, :], radius**2) - D.append(d) - NN.append(nn) + distances = [] + neighbors = [] + if metric == 'euclidean': + radius = radius**2 + elif metric == 'minkowski': + radius = radius**order + for vertex in range(n_vertices): + neighbor, distance = index.nn_radius(features[vertex, :], radius) + distances.append(distance) + neighbors.append(neighbor) index.free_index() if metric == 'euclidean': - # Flann returns squared distances. - D = list(map(np.sqrt, D)) - return NN, D + distances = list(map(np.sqrt, distances)) + elif metric == 'minkowski': + distances = list(map(lambda d: np.power(d, 1/order), distances)) + return neighbors, distances _nn_functions = { From ebc5c054ae36684a032374eaf3b83b1db582077a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 15:35:07 +0100 Subject: [PATCH 048/365] NNGraph: test consistency across backends --- pygsp/tests/test_graphs.py | 65 ++++++++++++-------------------------- 1 file changed, 20 insertions(+), 45 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c0142317..14291294 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -349,17 +349,19 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') - def test_nngraph(self, n_vertices=30): + def test_nngraph(self, n_vertices=25): """Test all the combinations of metric, kind, backend.""" Graph = graphs.NNGraph - data = np.random.RandomState(42).normal(size=(n_vertices, 3)) + data = np.random.RandomState(42).uniform(size=(n_vertices, 3)) metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'scipy-pdist', 'nmslib', - 'flann'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann', 'nmslib'] - for backend in backends: - for metric in metrics: - for kind in ['knn', 'radius']: + for metric in metrics: + for kind in ['knn', 'radius']: + params = dict(features=data, metric=metric, kind=kind, + radius=0.8) + ref = Graph(**params, backend='scipy-pdist') + for backend in backends: # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': self.assertRaises(ValueError, Graph, data, @@ -371,9 +373,16 @@ def test_nngraph(self, n_vertices=30): self.assertRaises(ValueError, Graph, data, kind=kind, backend=backend) else: - for standardize in [True, False]: - Graph(data, standardize=standardize, metric=metric, - kind=kind, backend=backend) + params['backend'] = backend + if backend == 'flann': + graph = Graph(**params, random_seed=42) + else: + graph = Graph(**params) + np.testing.assert_allclose(graph.W.toarray(), + ref.W.toarray(), rtol=1e-5) + + Graph(data, standardize=False) + Graph(data, standardize=True) # Invalid parameters. self.assertRaises(ValueError, Graph, data, metric='invalid') @@ -385,7 +394,7 @@ def test_nngraph(self, n_vertices=30): # Empty graph. if sys.version_info > (3, 4): # no assertLogs in python 2.7 - for backend in backends: + for backend in backends + ['scipy-pdist']: if backend == 'nmslib': continue # nmslib doesn't support radius with self.assertLogs(level='WARNING'): @@ -406,40 +415,6 @@ def test_nngraph(self, n_vertices=30): Graph(data, backend=backend, kind=kind, eps=1e-2) Graph(data, backend=backend, kind=kind, leafsize=9) - def test_nngraph_consistency(self): - features = np.arange(90).reshape(30, 3) - metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann', 'nmslib'] - num_neighbors = 4 - radius = 0.1 - - G = graphs.NNGraph(features, kind='knn', - backend='scipy-pdist', k=num_neighbors) - for backend in backends: - for metric in metrics: - if backend == 'flann' and metric == 'max_dist': - continue - if backend == 'nmslib' and metric == 'minkowski': - continue - Gt = graphs.NNGraph(features, kind='knn', - backend=backend, k=num_neighbors) - d = sparse.linalg.norm(G.W - Gt.W) - self.assertTrue(d < 0.01, 'Graphs (knn {}/{}) are not identical error={}'.format(backend, metric, d)) - - G = graphs.NNGraph(features, kind='radius', - backend='scipy-pdist', radius=radius) - for backend in backends: - for metric in metrics: - if backend == 'flann' and metric == 'max_dist': - continue - if backend == 'nmslib': - continue - Gt = graphs.NNGraph(features, kind='radius', - backend=backend, radius=radius) - d = sparse.linalg.norm(G.W - Gt.W, ord=1) - self.assertTrue(d < 0.01, - 'Graphs (radius {}/{}) are not identical error={}'.format(backend, metric, d)) - def test_bunny(self): graphs.Bunny() From 1167f52cb5bf4e80a5042df437f5705c37b8a771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 18:53:08 +0100 Subject: [PATCH 049/365] python 2.7 dict unpacking --- pygsp/tests/test_graphs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 14291294..295395a2 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -360,7 +360,7 @@ def test_nngraph(self, n_vertices=25): for kind in ['knn', 'radius']: params = dict(features=data, metric=metric, kind=kind, radius=0.8) - ref = Graph(**params, backend='scipy-pdist') + ref = Graph(backend='scipy-pdist', **params) for backend in backends: # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': @@ -375,7 +375,7 @@ def test_nngraph(self, n_vertices=25): else: params['backend'] = backend if backend == 'flann': - graph = Graph(**params, random_seed=42) + graph = Graph(random_seed=42, **params) else: graph = Graph(**params) np.testing.assert_allclose(graph.W.toarray(), From 3638cfdfaeaedbe4873152fb1958feef56e5a408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 00:57:02 +0100 Subject: [PATCH 050/365] pdist accepts no parameters --- pygsp/graphs/nngraphs/nngraph.py | 4 +++- pygsp/tests/test_graphs.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index bcaf7ffe..150fd0c9 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -126,7 +126,9 @@ def _radius_sp_ckdtree(features, radius, metric, order, params): return neighbors, distances -def _knn_sp_pdist(features, num_neighbors, metric, order, _): +def _knn_sp_pdist(features, num_neighbors, metric, order, params): + if params: + raise ValueError('unexpected parameters {}'.format(params)) if metric == 'minkowski': p = spatial.distance.pdist(features, metric=_metrics['scipy-pdist'][metric], diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 295395a2..c7568acd 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -414,6 +414,7 @@ def test_nngraph(self, n_vertices=25): for kind in ['knn', 'radius']: Graph(data, backend=backend, kind=kind, eps=1e-2) Graph(data, backend=backend, kind=kind, leafsize=9) + self.assertRaises(ValueError, Graph, data, backend='scipy-pdist', a=0) def test_bunny(self): graphs.Bunny() From 9b663aada4bfa293c4372d33cf82d401088910c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 01:37:48 +0100 Subject: [PATCH 051/365] NNGraph: test distance on a circle --- pygsp/tests/test_graphs.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c7568acd..32621016 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -381,6 +381,24 @@ def test_nngraph(self, n_vertices=25): np.testing.assert_allclose(graph.W.toarray(), ref.W.toarray(), rtol=1e-5) + # Distances between points on a circle. + angles = [0, 2 * np.pi / n_vertices] + points = np.stack([np.cos(angles), np.sin(angles)], axis=1) + distance = np.linalg.norm(points[0] - points[1]) + weight = np.exp(-distance**2) + column = np.zeros(n_vertices) + column[1] = weight + column[-1] = weight + adjacency = scipy.linalg.circulant(column) + for kind in ['knn', 'radius']: + for backend in backends + ['scipy-pdist']: + if backend == 'nmslib' and kind == 'radius': + continue # unsupported + data = graphs.Ring(n_vertices).coords + graph = Graph(data, kind=kind, k=2, radius=1.01*distance, + kernel_width=1, backend=backend) + np.testing.assert_allclose(graph.W.toarray(), adjacency) + Graph(data, standardize=False) Graph(data, standardize=True) From 4af4118940ff7e01a5a2510caf72ca4f942c28e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 01:55:31 +0100 Subject: [PATCH 052/365] NNGraph pdist: don't sort twice --- pygsp/graphs/nngraphs/nngraph.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 150fd0c9..c84d2a59 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -126,20 +126,18 @@ def _radius_sp_ckdtree(features, radius, metric, order, params): return neighbors, distances -def _knn_sp_pdist(features, num_neighbors, metric, order, params): +def _knn_sp_pdist(features, k, metric, order, params): if params: raise ValueError('unexpected parameters {}'.format(params)) if metric == 'minkowski': - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric], - p=order) + distances = spatial.distance.pdist(features, metric='minkowski', p=order) else: - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric]) - pd = spatial.distance.squareform(p) - pds = np.sort(pd)[:, :num_neighbors+1] - pdi = pd.argsort()[:, :num_neighbors+1] - return pdi, pds + distances = spatial.distance.pdist( + features, metric=_metrics['scipy-pdist'][metric]) + distances = spatial.distance.squareform(distances) + neighbors = np.argsort(distances)[:, :k+1] + distances = np.take_along_axis(distances, neighbors, axis=-1) + return neighbors, distances def _knn_nmslib(features, num_neighbors, metric, _, params): From 624af23a4e32fd56f38c9121d6c3af7e5e951a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 04:26:45 +0100 Subject: [PATCH 053/365] NNGraph: fuse knn and radius implementations --- pygsp/graphs/nngraphs/nngraph.py | 315 ++++++++++++------------------- pygsp/tests/test_graphs.py | 8 +- 2 files changed, 124 insertions(+), 199 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index c84d2a59..c980283d 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -12,216 +12,150 @@ from pygsp.graphs import Graph # prevent circular import in Python < 3.5 -# conversion between the FLANN conventions and the various backend functions -_metrics = { - 'scipy-pdist': { - 'euclidean': 'euclidean', - 'manhattan': 'cityblock', - 'max_dist': 'chebyshev', - 'minkowski': 'minkowski', - }, - 'scipy-kdtree': { +def _metric_kdtree(metric, order): + _metrics = { 'euclidean': 2, 'manhattan': 1, 'max_dist': np.inf, - 'minkowski': 0, - }, - 'scipy-ckdtree': { - 'euclidean': 2, - 'manhattan': 1, - 'max_dist': np.inf, - 'minkowski': 0, - }, - 'flann': { - 'euclidean': 'euclidean', - 'manhattan': 'manhattan', -# 'max_dist': 'max_dist', # produces incorrect results - 'minkowski': 'minkowski', - }, - 'nmslib': { - 'euclidean': 'l2', - 'manhattan': 'l1', - 'max_dist': 'linf', -# 'minkowski': 'lp', # unsupported + 'minkowski': order, } -} - - -def _import_cfl(): try: - import cyflann as cfl - except Exception as e: - raise ImportError('Cannot import cyflann. Choose another nearest ' - 'neighbors method or try to install it with ' - 'pip (or conda) install cyflann. ' - 'Original exception: {}'.format(e)) - return cfl + return _metrics[metric] + except KeyError: + raise ValueError('unknown metric {} for scipy-kdtree'.format(metric)) -def _import_nmslib(): - try: - import nmslib as nms - except Exception: - raise ImportError('Cannot import nmslib. Choose another nearest ' - 'neighbors method or try to install it with ' - 'pip (or conda) install nmslib.') - return nms +def _scipy_pdist(features, metric, order, kind, k, radius, params): + if params: + raise ValueError('unexpected parameters {}'.format(params)) + metric = 'cityblock' if metric == 'manhattan' else metric + metric = 'chebyshev' if metric == 'max_dist' else metric + params = dict(metric=metric) + if metric == 'minkowski': + params['p'] = order + dist = spatial.distance.pdist(features, **params) + dist = spatial.distance.squareform(dist) + if kind == 'knn': + neighbors = np.argsort(dist)[:, :k+1] + distances = np.take_along_axis(dist, neighbors, axis=-1) + elif kind == 'radius': + distances = [] + neighbors = [] + for distance in dist: + neighbor = np.flatnonzero(distance < radius) + neighbors.append(neighbor) + distances.append(distance[neighbor]) + return neighbors, distances -def _knn_sp_kdtree(features, k, metric, order, params): - p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] +def _scipy_kdtree(features, metric, order, kind, k, radius, params): + metric = _metric_kdtree(metric, order) eps = params.pop('eps', 0) tree = spatial.KDTree(features, **params) - distances, neighbors = tree.query(features, k=k+1, p=p, eps=eps) + params = dict(p=metric, eps=eps) + if kind == 'knn': + params['k'] = k + 1 + elif kind == 'radius': + params['k'] = None + params['distance_upper_bound'] = radius + distances, neighbors = tree.query(features, **params) return neighbors, distances -def _knn_sp_ckdtree(features, k, metric, order, params): - p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] +def _scipy_ckdtree(features, metric, order, kind, k, radius, params): + metric = _metric_kdtree(metric, order) eps = params.pop('eps', 0) - kdt = spatial.cKDTree(features, **params) - distances, neighbors = kdt.query(features, k=k+1, p=p, eps=eps, n_jobs=-1) - return neighbors, distances - - -def _knn_flann(features, k, metric, order, params): - cfl = _import_cfl() + tree = spatial.cKDTree(features, **params) + params = dict(p=metric, eps=eps, n_jobs=-1) + if kind == 'knn': + params['k'] = k + 1 + elif kind == 'radius': + params['k'] = features.shape[0] # number of vertices + params['distance_upper_bound'] = radius + distances, neighbors = tree.query(features, **params) + if kind == 'knn': + return neighbors, distances + elif kind == 'radius': + dist = [] + neigh = [] + for distance, neighbor in zip(distances, neighbors): + mask = (distance != np.inf) + dist.append(distance[mask]) + neigh.append(neighbor[mask]) + return neigh, dist + + +def _flann(features, metric, order, kind, k, radius, params): + if metric == 'max_dist': + raise ValueError('flann gives wrong results for metric="max_dist".') + try: + import cyflann as cfl + except Exception as e: + raise ImportError('Cannot import cyflann. Choose another nearest ' + 'neighbors backend or try to install it with ' + 'pip (or conda) install cyflann. ' + 'Original exception: {}'.format(e)) cfl.set_distance_type(metric, order=order) index = cfl.FLANNIndex() index.build_index(features, **params) - # Default FLANN parameters (I tried changing the algorithm and - # testing performance on huge matrices, but the default one - # seems to work best). - neighbors, distances = index.nn_index(features, k+1) + # I tried changing the algorithm and testing performance on huge matrices, + # but the default parameters seems to work best. + if kind == 'knn': + neighbors, distances = index.nn_index(features, k+1) + if metric == 'euclidean': + np.sqrt(distances, out=distances) + elif metric == 'minkowski': + np.power(distances, 1/order, out=distances) + elif kind == 'radius': + distances = [] + neighbors = [] + if metric == 'euclidean': + radius = radius**2 + elif metric == 'minkowski': + radius = radius**order + n_vertices, _ = features.shape + for vertex in range(n_vertices): + neighbor, distance = index.nn_radius(features[vertex, :], radius) + distances.append(distance) + neighbors.append(neighbor) + if metric == 'euclidean': + distances = list(map(np.sqrt, distances)) + elif metric == 'minkowski': + distances = list(map(lambda d: np.power(d, 1/order), distances)) index.free_index() - if metric == 'euclidean': - np.sqrt(distances, out=distances) - elif metric == 'minkowski': - np.power(distances, 1/order, out=distances) - return neighbors, distances - - -def _radius_sp_kdtree(features, radius, metric, order, params): - p = order if metric == 'minkowski' else _metrics['scipy-kdtree'][metric] - eps = params.pop('eps', 0) - tree = spatial.KDTree(features, **params) - distances, neighbors = tree.query(features, p=p, eps=eps, k=None, - distance_upper_bound=radius) - return neighbors, distances - - -def _radius_sp_ckdtree(features, radius, metric, order, params): - p = order if metric == 'minkowski' else _metrics['scipy-ckdtree'][metric] - n_vertices, _ = features.shape - eps = params.pop('eps', 0) - tree = spatial.cKDTree(features, **params) - D, NN = tree.query(features, k=n_vertices, distance_upper_bound=radius, - p=p, eps=eps, n_jobs=-1) - distances = [] - neighbors = [] - for d, n in zip(D, NN): - mask = (d != np.inf) - distances.append(d[mask]) - neighbors.append(n[mask]) return neighbors, distances -def _knn_sp_pdist(features, k, metric, order, params): - if params: - raise ValueError('unexpected parameters {}'.format(params)) +def _nmslib(features, metric, order, kind, k, _, params): + if kind == 'radius': + raise ValueError('nmslib does not support kind="radius".') if metric == 'minkowski': - distances = spatial.distance.pdist(features, metric='minkowski', p=order) - else: - distances = spatial.distance.pdist( - features, metric=_metrics['scipy-pdist'][metric]) - distances = spatial.distance.squareform(distances) - neighbors = np.argsort(distances)[:, :k+1] - distances = np.take_along_axis(distances, neighbors, axis=-1) - return neighbors, distances - - -def _knn_nmslib(features, num_neighbors, metric, _, params): + raise ValueError('nmslib does not support metric="minkowski".') + try: + import nmslib as nms + except Exception: + raise ImportError('Cannot import nmslib. Choose another nearest ' + 'neighbors method or try to install it with ' + 'pip (or conda) install nmslib.') n_vertices, _ = features.shape - ncpu = multiprocessing.cpu_count() - nms = _import_nmslib() params_index = params.pop('index', None) params_query = params.pop('query', None) - index = nms.init(space=_metrics['nmslib'][metric], **params) + metric = 'l2' if metric == 'euclidean' else metric + metric = 'l1' if metric == 'manhattan' else metric + metric = 'linf' if metric == 'max_dist' else metric + index = nms.init(space=metric, **params) index.addDataPointBatch(features) index.createIndex(params_index) if params_query is not None: index.setQueryTimeParams(params_query) - q = index.knnQueryBatch(features, k=num_neighbors+1, - num_threads=int(ncpu/2)) - nn, d = zip(*q) - D = np.concatenate(d).reshape(n_vertices, num_neighbors+1) - NN = np.concatenate(nn).reshape(n_vertices, num_neighbors+1) - return NN, D - - -def _radius_sp_pdist(features, radius, metric, order, _): - n_vertices, _ = features.shape - if metric == 'minkowski': - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric], - p=order) - else: - p = spatial.distance.pdist(features, - metric=_metrics['scipy-pdist'][metric]) - pd = spatial.distance.squareform(p) - pdf = pd < radius - D = [] - NN = [] - for k in range(n_vertices): - v = pd[k, pdf[k, :]] - d = pd[k, :].argsort() - # use the same conventions as in scipy.distance.kdtree - NN.append(d[0:len(v)]) - D.append(np.sort(v)) - return NN, D - - -def _radius_flann(features, radius, metric, order, params): - n_vertices, _ = features.shape - cfl = _import_cfl() - cfl.set_distance_type(metric, order=order) - index = cfl.FLANNIndex() - index.build_index(features, **params) - distances = [] - neighbors = [] - if metric == 'euclidean': - radius = radius**2 - elif metric == 'minkowski': - radius = radius**order - for vertex in range(n_vertices): - neighbor, distance = index.nn_radius(features[vertex, :], radius) - distances.append(distance) - neighbors.append(neighbor) - index.free_index() - if metric == 'euclidean': - distances = list(map(np.sqrt, distances)) - elif metric == 'minkowski': - distances = list(map(lambda d: np.power(d, 1/order), distances)) + ncpu = multiprocessing.cpu_count() + q = index.knnQueryBatch(features, k=k+1, num_threads=int(ncpu/2)) + neighbors, distances = zip(*q) + distances = np.concatenate(distances).reshape(n_vertices, k+1) + neighbors = np.concatenate(neighbors).reshape(n_vertices, k+1) return neighbors, distances -_nn_functions = { - 'knn': { - 'scipy-pdist': _knn_sp_pdist, - 'scipy-kdtree': _knn_sp_kdtree, - 'scipy-ckdtree': _knn_sp_ckdtree, - 'flann': _knn_flann, - 'nmslib': _knn_nmslib, - }, - 'radius': { - 'scipy-pdist': _radius_sp_pdist, - 'scipy-kdtree': _radius_sp_kdtree, - 'scipy-ckdtree': _radius_sp_ckdtree, - 'flann': _radius_flann, - }, -} - - class NNGraph(Graph): r"""Nearest-neighbor graph. @@ -250,6 +184,8 @@ class NNGraph(Graph): and standard deviation of 1 (unit variance). metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional Metric used to compute pairwise distances. + More metrics may be supported for some backends. + Please refer to the documentation of the chosen backend. order : float, optional The order of the Minkowski distance for ``metric='minkowski'``. kind : {'knn', 'radius'}, optional @@ -310,19 +246,9 @@ def __init__(self, features, standardize=False, except KeyError: pass - if _metrics['scipy-pdist'].get(metric) is None: - raise ValueError('Invalid metric "{}".'.format(metric)) - if _nn_functions.get(kind) is None: + if kind not in ['knn', 'radius']: raise ValueError('Invalid kind "{}".'.format(kind)) - if backend not in _metrics.keys(): - raise ValueError('Invalid backend "{}".'.format(backend)) - if _metrics[backend].get(metric) is None: - raise ValueError('{} does not support metric="{}".'.format( - backend, metric)) - if _nn_functions[kind].get(backend) is None: - raise ValueError('{} does not support kind="{}".'.format( - backend, kind)) - if not (1 <= k < n_vertices): + if (kind == 'knn') and not (1 <= k < n_vertices): raise ValueError('The number of neighbors (k={}) must be greater ' 'than 0 and smaller than the number of vertices ' '({}).'.format(k, n_vertices)) @@ -334,11 +260,12 @@ def __init__(self, features, standardize=False, features = features - np.mean(features, axis=0) features /= np.std(features, axis=0) - nn_function = _nn_functions[kind][backend] - if kind == 'knn': - neighbors, distances = nn_function(features, k, metric, order, kwargs) - elif kind == 'radius': - neighbors, distances = nn_function(features, radius, metric, order, kwargs) + try: + function = globals()['_' + backend.replace('-', '_')] + except KeyError: + raise ValueError('Invalid backend "{}".'.format(backend)) + neighbors, distances = function(features, metric, order, + kind, k, radius, kwargs) n_edges = [len(n) - 1 for n in neighbors] # remove distance to self diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 32621016..474efb1e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -423,15 +423,13 @@ def test_nngraph(self, n_vertices=25): # Backend parameters. Graph(data, lap_type='normalized') Graph(data, plotting=dict(vertex_size=10)) - Graph(data, backend='flann', kind='knn', algorithm='kmeans') - Graph(data, backend='flann', kind='radius', random_seed=0) + Graph(data, backend='flann', algorithm='kmeans') Graph(data, backend='nmslib', method='vptree') Graph(data, backend='nmslib', index=dict(post=2)) Graph(data, backend='nmslib', query=dict(efSearch=10)) for backend in ['scipy-kdtree', 'scipy-ckdtree']: - for kind in ['knn', 'radius']: - Graph(data, backend=backend, kind=kind, eps=1e-2) - Graph(data, backend=backend, kind=kind, leafsize=9) + Graph(data, backend=backend, eps=1e-2) + Graph(data, backend=backend, leafsize=9) self.assertRaises(ValueError, Graph, data, backend='scipy-pdist', a=0) def test_bunny(self): From 043579e34a6199b2d3551dd4091465b8f54f806f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 05:00:33 +0100 Subject: [PATCH 054/365] nmslib: number of thread is automatically set to max By default, num_threads = 0. https://github.com/nmslib/nmslib/blob/712be85c878512686e3c63e80a766b3d4213cfef/python_bindings/nmslib.cc#L557 Then, numThreads = std::thread::hardware_concurrency() sets it to the number of concurrent threads supported by the implementation. https://github.com/nmslib/nmslib/blob/314f48e0f86efafe9f6ad853236603400030a0ac/similarity_search/include/thread_pool.h#L64 --- pygsp/graphs/nngraphs/nngraph.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index c980283d..457ea44e 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -2,9 +2,6 @@ from __future__ import division -import multiprocessing -import traceback - import numpy as np from scipy import sparse, spatial @@ -148,9 +145,8 @@ def _nmslib(features, metric, order, kind, k, _, params): index.createIndex(params_index) if params_query is not None: index.setQueryTimeParams(params_query) - ncpu = multiprocessing.cpu_count() - q = index.knnQueryBatch(features, k=k+1, num_threads=int(ncpu/2)) - neighbors, distances = zip(*q) + results = index.knnQueryBatch(features, k=k+1) + neighbors, distances = zip(*results) distances = np.concatenate(distances).reshape(n_vertices, k+1) neighbors = np.concatenate(neighbors).reshape(n_vertices, k+1) return neighbors, distances From 1d22376784356066399c4d841e3b884ecaed0f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 12:04:49 +0100 Subject: [PATCH 055/365] order consistent with metric --- pygsp/graphs/nngraphs/nngraph.py | 36 +++++++++++++++----------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 457ea44e..212db4bc 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -9,19 +9,6 @@ from pygsp.graphs import Graph # prevent circular import in Python < 3.5 -def _metric_kdtree(metric, order): - _metrics = { - 'euclidean': 2, - 'manhattan': 1, - 'max_dist': np.inf, - 'minkowski': order, - } - try: - return _metrics[metric] - except KeyError: - raise ValueError('unknown metric {} for scipy-kdtree'.format(metric)) - - def _scipy_pdist(features, metric, order, kind, k, radius, params): if params: raise ValueError('unexpected parameters {}'.format(params)) @@ -45,11 +32,12 @@ def _scipy_pdist(features, metric, order, kind, k, radius, params): return neighbors, distances -def _scipy_kdtree(features, metric, order, kind, k, radius, params): - metric = _metric_kdtree(metric, order) +def _scipy_kdtree(features, _, order, kind, k, radius, params): + if order is None: + raise ValueError('invalid metric for scipy-kdtree') eps = params.pop('eps', 0) tree = spatial.KDTree(features, **params) - params = dict(p=metric, eps=eps) + params = dict(p=order, eps=eps) if kind == 'knn': params['k'] = k + 1 elif kind == 'radius': @@ -59,11 +47,12 @@ def _scipy_kdtree(features, metric, order, kind, k, radius, params): return neighbors, distances -def _scipy_ckdtree(features, metric, order, kind, k, radius, params): - metric = _metric_kdtree(metric, order) +def _scipy_ckdtree(features, _, order, kind, k, radius, params): + if order is None: + raise ValueError('invalid metric for scipy-kdtree') eps = params.pop('eps', 0) tree = spatial.cKDTree(features, **params) - params = dict(p=metric, eps=eps, n_jobs=-1) + params = dict(p=order, eps=eps, n_jobs=-1) if kind == 'knn': params['k'] = k + 1 elif kind == 'radius': @@ -251,6 +240,15 @@ def __init__(self, features, standardize=False, if (radius is not None) and (radius <= 0): raise ValueError('The radius must be greater than 0.') + # Order consistent with metric (used by kdtree and ckdtree). + _orders = { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf, + 'minkowski': order, + } + order = _orders.pop(metric, None) + if standardize: # Don't alter the original data (users would be surprised). features = features - np.mean(features, axis=0) From 076307634a30e48785405c36e9d04ec4eb453191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 12:08:42 +0100 Subject: [PATCH 056/365] cleaner error handling --- pygsp/graphs/nngraphs/nngraph.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 212db4bc..3dd22ab6 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -231,14 +231,16 @@ def __init__(self, features, standardize=False, except KeyError: pass - if kind not in ['knn', 'radius']: + if kind == 'knn': + if not 1 <= k < n_vertices: + raise ValueError('The number of neighbors (k={}) must be ' + 'greater than 0 and smaller than the number ' + 'of vertices ({}).'.format(k, n_vertices)) + elif kind == 'radius': + if (radius is not None) and (radius <= 0): + raise ValueError('The radius must be greater than 0.') + else: raise ValueError('Invalid kind "{}".'.format(kind)) - if (kind == 'knn') and not (1 <= k < n_vertices): - raise ValueError('The number of neighbors (k={}) must be greater ' - 'than 0 and smaller than the number of vertices ' - '({}).'.format(k, n_vertices)) - if (radius is not None) and (radius <= 0): - raise ValueError('The radius must be greater than 0.') # Order consistent with metric (used by kdtree and ckdtree). _orders = { From 3f0c2b55dc1d95c6490ef902bc8c8d1e1a0ccc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Feb 2019 16:54:03 +0100 Subject: [PATCH 057/365] nngraph: test standardization --- pygsp/tests/test_graphs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 474efb1e..52d4aeec 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -399,8 +399,9 @@ def test_nngraph(self, n_vertices=25): kernel_width=1, backend=backend) np.testing.assert_allclose(graph.W.toarray(), adjacency) - Graph(data, standardize=False) - Graph(data, standardize=True) + graph = Graph(data, standardize=True) + np.testing.assert_allclose(np.mean(graph.coords, axis=0), 0, atol=1e-7) + np.testing.assert_allclose(np.std(graph.coords, axis=0), 1) # Invalid parameters. self.assertRaises(ValueError, Graph, data, metric='invalid') From 26e12e37ed5be39ad9db36f7953d0b95c52263fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Feb 2019 00:51:30 +0100 Subject: [PATCH 058/365] nngraph: radius estimation --- pygsp/graphs/nngraphs/nngraph.py | 70 ++++++++++++++++++++++---------- pygsp/tests/test_graphs.py | 14 ++++--- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3dd22ab6..74248e1d 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -179,8 +179,17 @@ class NNGraph(Graph): k : int, optional Number of neighbors considered when building a k-NN graph with ``type='knn'``. - radius : float, optional + radius : float or {'estimate', 'estimate-knn'}, optional Radius of the ball when building a radius graph with ``type='radius'``. + It is hard to set an optimal radius. If too small, some vertices won't + be connected to any other vertex. If too high, vertices will be + connected to many other vertices and the graph won't be sparse (high + average degree). If no good radius is known a priori, we can estimate + one. ``'estimate'`` sets the radius as the expected average distance + between vertices for a uniform sampling of the ambient space. + ``'estimate-knn'`` first builds a knn graph and sets the radius to the + average distance. ``'estimate-knn'`` usually gives a better estimation + but is more costly. ``'estimate'`` can be better in low dimension. kernel_width : float, optional Width of the Gaussian kernel. By default, it is set to the average of the distances of neighboring vertices. @@ -217,12 +226,12 @@ class NNGraph(Graph): def __init__(self, features, standardize=False, metric='euclidean', order=3, - kind='knn', k=10, radius=0.01, + kind='knn', k=10, radius='estimate-knn', kernel_width=None, backend='scipy-ckdtree', **kwargs): - n_vertices, _ = features.shape + n_vertices, dimensionality = features.shape params_graph = dict() for key in ['lap_type', 'plotting']: @@ -231,16 +240,10 @@ def __init__(self, features, standardize=False, except KeyError: pass - if kind == 'knn': - if not 1 <= k < n_vertices: - raise ValueError('The number of neighbors (k={}) must be ' - 'greater than 0 and smaller than the number ' - 'of vertices ({}).'.format(k, n_vertices)) - elif kind == 'radius': - if (radius is not None) and (radius <= 0): - raise ValueError('The radius must be greater than 0.') - else: - raise ValueError('Invalid kind "{}".'.format(kind)) + if standardize: + # Don't alter the original data (users would be surprised). + features = features - np.mean(features, axis=0) + features /= np.std(features, axis=0) # Order consistent with metric (used by kdtree and ckdtree). _orders = { @@ -251,10 +254,28 @@ def __init__(self, features, standardize=False, } order = _orders.pop(metric, None) - if standardize: - # Don't alter the original data (users would be surprised). - features = features - np.mean(features, axis=0) - features /= np.std(features, axis=0) + if kind == 'knn': + if not 1 <= k < n_vertices: + raise ValueError('The number of neighbors (k={}) must be ' + 'greater than 0 and smaller than the number ' + 'of vertices ({}).'.format(k, n_vertices)) + radius = None + elif kind == 'radius': + if radius == 'estimate': + maximums = np.amax(features, axis=0) + minimums = np.amin(features, axis=0) + distance_max = np.linalg.norm(maximums - minimums, order) + radius = distance_max / np.power(n_vertices, 1/dimensionality) + elif radius == 'estimate-knn': + graph = NNGraph(features, standardize=standardize, + metric=metric, order=order, kind='knn', k=k, + kernel_width=None, backend=backend, **kwargs) + radius = graph.kernel_width + elif radius <= 0: + raise ValueError('The radius must be greater than 0.') + k = None + else: + raise ValueError('Invalid kind "{}".'.format(kind)) try: function = globals()['_' + backend.replace('-', '_')] @@ -314,13 +335,18 @@ def kernel(distance, width): super(NNGraph, self).__init__(W=W, coords=features, **params_graph) def _get_extra_repr(self): - return { + attrs = { 'standardize': self.standardize, 'metric': self.metric, 'order': self.order, 'kind': self.kind, - 'k': self.k, - 'radius': '{:.2f}'.format(self.radius), - 'kernel_width': '{:.2f}'.format(self.kernel_width), - 'backend': self.backend, } + if self.k is not None: + attrs['k'] = self.k + if self.radius is not None: + attrs['radius'] = '{:.2e}'.format(self.radius) + attrs.update({ + 'kernel_width': '{:.2e}'.format(self.kernel_width), + 'backend': self.backend, + }) + return attrs diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 52d4aeec..e4f4879f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -349,7 +349,7 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') - def test_nngraph(self, n_vertices=25): + def test_nngraph(self, n_vertices=24): """Test all the combinations of metric, kind, backend.""" Graph = graphs.NNGraph data = np.random.RandomState(42).uniform(size=(n_vertices, 3)) @@ -358,8 +358,7 @@ def test_nngraph(self, n_vertices=25): for metric in metrics: for kind in ['knn', 'radius']: - params = dict(features=data, metric=metric, kind=kind, - radius=0.8) + params = dict(features=data, metric=metric, kind=kind) ref = Graph(backend='scipy-pdist', **params) for backend in backends: # Unsupported combinations. @@ -375,7 +374,7 @@ def test_nngraph(self, n_vertices=25): else: params['backend'] = backend if backend == 'flann': - graph = Graph(random_seed=42, **params) + graph = Graph(random_seed=0, **params) else: graph = Graph(**params) np.testing.assert_allclose(graph.W.toarray(), @@ -390,15 +389,20 @@ def test_nngraph(self, n_vertices=25): column[1] = weight column[-1] = weight adjacency = scipy.linalg.circulant(column) + data = graphs.Ring(n_vertices).coords for kind in ['knn', 'radius']: for backend in backends + ['scipy-pdist']: if backend == 'nmslib' and kind == 'radius': continue # unsupported - data = graphs.Ring(n_vertices).coords graph = Graph(data, kind=kind, k=2, radius=1.01*distance, kernel_width=1, backend=backend) np.testing.assert_allclose(graph.W.toarray(), adjacency) + graph = Graph(data, kind='radius', radius='estimate') + np.testing.assert_allclose(graph.radius, np.sqrt(8 / n_vertices)) + graph = Graph(data, kind='radius', k=2, radius='estimate-knn') + np.testing.assert_allclose(graph.radius, distance) + graph = Graph(data, standardize=True) np.testing.assert_allclose(np.mean(graph.coords, axis=0), 0, atol=1e-7) np.testing.assert_allclose(np.std(graph.coords, axis=0), 1) From 080bb5c623d2e75d0fdbad9ffec4cba9605d4459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Feb 2019 01:10:40 +0100 Subject: [PATCH 059/365] fix others uses of radius --- pygsp/graphs/nngraphs/cube.py | 13 ++++++++----- pygsp/graphs/nngraphs/sphere.py | 11 ++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 2328c2cb..b9cb1d60 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -10,12 +10,12 @@ class Cube(NNGraph): Parameters ---------- - radius : float - Edge lenght (default = 1) nb_pts : int Number of vertices (default = 300) nb_dim : int Dimension (default = 3) + length : float + Edge length (default = 1) sampling : string Variance of the distance kernel (default = 'random') (Can now only be 'random') @@ -35,20 +35,23 @@ class Cube(NNGraph): """ def __init__(self, - radius=1, nb_pts=300, nb_dim=3, + length=1, sampling='random', seed=None, **kwargs): - self.radius = radius self.nb_pts = nb_pts self.nb_dim = nb_dim + self.length = length self.sampling = sampling self.seed = seed rs = np.random.RandomState(seed) + if length != 1: + raise NotImplementedError('Only length=1 is implemented.') + if self.nb_dim > 3: raise NotImplementedError("Dimension > 3 not supported yet!") @@ -92,7 +95,7 @@ def __init__(self, super(Cube, self).__init__(pts, k=10, plotting=plotting, **kwargs) def _get_extra_repr(self): - attrs = {'radius': '{:.2f}'.format(self.radius), + attrs = {'length': '{:.2e}'.format(self.length), 'nb_pts': self.nb_pts, 'nb_dim': self.nb_dim, 'sampling': self.sampling, diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index f323f562..1262b922 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -10,12 +10,12 @@ class Sphere(NNGraph): Parameters ---------- - radius : float - Radius of the sphere (default = 1) nb_pts : int Number of vertices (default = 300) nb_dim : int Dimension (default = 3) + diameter : float + Radius of the sphere (default = 2) sampling : string Variance of the distance kernel (default = 'random') (Can now only be 'random') @@ -35,14 +35,14 @@ class Sphere(NNGraph): """ def __init__(self, - radius=1, nb_pts=300, nb_dim=3, + diameter=2, sampling='random', seed=None, **kwargs): - self.radius = radius + self.diameter = diameter self.nb_pts = nb_pts self.nb_dim = nb_dim self.sampling = sampling @@ -55,6 +55,7 @@ def __init__(self, for i in range(self.nb_pts): pts[i] /= np.linalg.norm(pts[i]) + pts[i] *= (diameter / 2) else: @@ -67,7 +68,7 @@ def __init__(self, super(Sphere, self).__init__(pts, k=10, plotting=plotting, **kwargs) def _get_extra_repr(self): - attrs = {'radius': '{:.2f}'.format(self.radius), + attrs = {'diameter': '{:.2e}'.format(self.diameter), 'nb_pts': self.nb_pts, 'nb_dim': self.nb_dim, 'sampling': self.sampling, From dad41058475c581d266b01f3104340afbc4b44b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 17:48:07 +0100 Subject: [PATCH 060/365] nngraph: check shape of features --- pygsp/graphs/nngraphs/nngraph.py | 3 +++ pygsp/tests/test_graphs.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 74248e1d..83d20530 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -231,6 +231,9 @@ def __init__(self, features, standardize=False, backend='scipy-ckdtree', **kwargs): + features = np.asanyarray(features) + if features.ndim != 2: + raise ValueError('features should be #vertices x dimensionality') n_vertices, dimensionality = features.shape params_graph = dict() diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index e4f4879f..dd203cb7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -408,6 +408,8 @@ def test_nngraph(self, n_vertices=24): np.testing.assert_allclose(np.std(graph.coords, axis=0), 1) # Invalid parameters. + self.assertRaises(ValueError, Graph, np.ones(n_vertices)) + self.assertRaises(ValueError, Graph, np.ones((n_vertices, 3, 4))) self.assertRaises(ValueError, Graph, data, metric='invalid') self.assertRaises(ValueError, Graph, data, kind='invalid') self.assertRaises(ValueError, Graph, data, backend='invalid') From f544e1e8d82b3b9faf9f58e61807a1494dc5555b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 18:05:53 +0100 Subject: [PATCH 061/365] nngraph: fix definition of gaussian kernel --- doc/tutorials/optimization.rst | 4 ++-- pygsp/filters/filter.py | 4 ++-- pygsp/graphs/fourier.py | 2 +- pygsp/graphs/nngraphs/nngraph.py | 2 +- pygsp/tests/test_filters.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index dd6d91ad..0fd16457 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -85,7 +85,7 @@ We start with the graph TV regularization. We will use the :class:`pyunlocbox.so >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 2.138668e+02 + objective function f(sol) = 2.367630e+02 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob1['sol']) @@ -107,7 +107,7 @@ This figure shows the label signal recovered by graph total variation regulariza >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 7.413918e+01 + objective function f(sol) = 3.668690e+01 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob2['sol']) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 56d5508d..f267025d 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -254,7 +254,7 @@ def filter(self, s, method='chebyshev', order=30): >>> _ = G.plot(s1, ax=axes[0]) >>> _ = G.plot(s2, ax=axes[1]) >>> print('{:.5f}'.format(np.linalg.norm(s1 - s2))) - 0.28049 + 0.27175 Perfect reconstruction with Itersine, a tight frame: @@ -449,7 +449,7 @@ def estimate_frame_bounds(self, x=None): A=1.708, B=2.359 >>> A, B = g.estimate_frame_bounds(G.e) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.712, B=2.348 + A=1.720, B=2.355 The frame bounds can be seen in the plot of the filter bank as the minimum and maximum of their squared sum (the black curve): diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 286b3252..43170fe6 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -78,7 +78,7 @@ def coherence(self): >>> graph.compute_fourier_basis() >>> minimum = 1 / np.sqrt(graph.n_vertices) >>> print('{:.2f} in [{:.2f}, 1]'.format(graph.coherence, minimum)) - 0.93 in [0.12, 1] + 0.91 in [0.12, 1] >>> >>> # Plot the most localized eigenvector. >>> import matplotlib.pyplot as plt diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 83d20530..b962ab50 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -321,7 +321,7 @@ def __init__(self, features, standardize=False, # Users can easily do the above. def kernel(distance, width): - return np.exp(-distance**2 / width) + return np.exp(-distance**2 / width**2) W.data = kernel(W.data, kernel_width) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index fe093da0..cef1689d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -149,7 +149,7 @@ def test_frame(self): def get_frame(freq_response): return self._G.U.dot(np.diag(freq_response).dot(self._G.U.T)) gL = np.concatenate([get_frame(gl) for gl in g.evaluate(self._G.e)]) - np.testing.assert_allclose(gL1, gL) + np.testing.assert_allclose(gL1, gL, atol=1e-10) np.testing.assert_allclose(gL2, gL, atol=1e-10) def test_complement(self, frame_bound=2.5): @@ -228,7 +228,7 @@ def test_modulation_gabor(self): f2 = filters.Gabor(self._G, f) s1 = f1.filter(self._signal) s2 = f2.filter(self._signal) - np.testing.assert_allclose(s1, s2, atol=1e-5) + np.testing.assert_allclose(s1, -s2, atol=1e-5) def test_halfcosine(self): f = filters.HalfCosine(self._G, Nf=4) From 9c8e86e3bb7b8131de7570b7a247f0b365818887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 18:19:58 +0100 Subject: [PATCH 062/365] nngraph: allow users to choose the similarity kernel --- doc/tutorials/optimization.rst | 4 +- pygsp/filters/filter.py | 4 +- pygsp/graphs/nngraphs/nngraph.py | 133 ++++++++++++++++++++++++++----- pygsp/tests/test_graphs.py | 11 ++- 4 files changed, 128 insertions(+), 24 deletions(-) diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index 0fd16457..b3740d85 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -85,7 +85,7 @@ We start with the graph TV regularization. We will use the :class:`pyunlocbox.so >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 2.367630e+02 + objective function f(sol) = 2.256055e+02 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob1['sol']) @@ -107,7 +107,7 @@ This figure shows the label signal recovered by graph total variation regulariza >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: - objective function f(sol) = 3.668690e+01 + objective function f(sol) = 4.376481e+01 stopping criterion: MAXIT >>> >>> fig, ax = G.plot(prob2['sol']) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index f267025d..5abc1169 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -254,7 +254,7 @@ def filter(self, s, method='chebyshev', order=30): >>> _ = G.plot(s1, ax=axes[0]) >>> _ = G.plot(s2, ax=axes[1]) >>> print('{:.5f}'.format(np.linalg.norm(s1 - s2))) - 0.27175 + 0.26995 Perfect reconstruction with Itersine, a tight frame: @@ -449,7 +449,7 @@ def estimate_frame_bounds(self, x=None): A=1.708, B=2.359 >>> A, B = g.estimate_frame_bounds(G.e) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.720, B=2.355 + A=1.839, B=2.359 The frame bounds can be seen in the plot of the filter bank as the minimum and maximum of their squared sum (the black curve): diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index b962ab50..8b9ffa98 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -2,6 +2,8 @@ from __future__ import division +from functools import partial + import numpy as np from scipy import sparse, spatial @@ -147,13 +149,17 @@ class NNGraph(Graph): The nearest-neighbor graph is built from a set of features, where the edge weight between vertices :math:`v_i` and :math:`v_j` is given by - .. math:: A(i,j) = \exp \left( -\frac{d^2(v_i, v_j)}{\sigma^2} \right), + .. math:: A(i,j) = k \left( \frac{d(v_i, v_j)}{\sigma} \right), where :math:`d(v_i, v_j)` is a distance measure between some representation - (the features) of :math:`v_i` and :math:`v_j`. For example, the features - might be the 3D coordinates of points in a point cloud. Then, if - ``metric='euclidean'``, :math:`d(v_i, v_j) = \| x_i - x_j \|_2`, where - :math:`x_i` is the 3D position of vertex :math:`v_i`. + (the features) of :math:`v_i` and :math:`v_j`, :math:`k` is a kernel + function that transforms a distance in :math:`[0, \infty]` to a similarity + measure in :math:`[0, 1]`, and :math:`\sigma` is the kernel width. + + For example, the features might be the 3D coordinates of points in a point + cloud. Then, if ``metric='euclidean'`` and ``kernel='gaussian'`` (the + defaults), :math:`A(i,j) = \exp(-\log(2) \| x_i - x_j \|_2^2 / \sigma^2)`, + where :math:`x_i` is the 3D position of vertex :math:`v_i`. The similarity matrix :math:`A` is sparsified by either keeping the ``k`` closest vertices for each vertex (if ``type='knn'``), or by setting to zero @@ -190,9 +196,30 @@ class NNGraph(Graph): ``'estimate-knn'`` first builds a knn graph and sets the radius to the average distance. ``'estimate-knn'`` usually gives a better estimation but is more costly. ``'estimate'`` can be better in low dimension. + kernel : string or function + The function :math:`k` that transforms a distance to a similarity. + The following kernels are pre-defined. + + * ``'gaussian'`` defines the Gaussian, also known as the radial basis + function (RBF), kernel :math:`k(d) = \exp(-\log(2) d^2)`. + * ``'exponential'`` defines the kernel :math:`k(d) = \exp(-\log(2) d)`. + * ``'rectangular'`` returns 1 if :math:`d < 1` and 0 otherwise. + * ``'triangular'`` defines the kernel :math:`k(d) = \max(1 - d/2, 0)`. + * Other kernels are ``'tricube'``, ``'triweight'``, ``'quartic'``, + ``'epanechnikov'``, ``'logistic'``, and ``'sigmoid'``. + See `Wikipedia `_. + + Another option is to pass a function that takes a vector of pairwise + distances and returns the similarities. All the predefined kernels + return a similarity of 0.5 when the distance is one. + An example of custom kernel is ``kernel=lambda d: d.min() / d``. kernel_width : float, optional - Width of the Gaussian kernel. By default, it is set to the average of - the distances of neighboring vertices. + Control the width, also known as the bandwidth, :math:`\sigma` of the + kernel by scaling the distances as ``distances / kernel_width`` before + calling the kernel function. + By default, it is set to the average of all computed distances. + When building a radius graph, it's common to set it as a function of + the radius, such as ``radius / 2``. backend : string, optional * ``'scipy-pdist'`` uses :func:`scipy.spatial.distance.pdist` to compute pairwise distances. The method is brute force and computes @@ -222,15 +249,34 @@ class NNGraph(Graph): >>> _ = axes[0].spy(G.W, markersize=5) >>> _ = G.plot(ax=axes[1]) + Plot all the pre-defined kernels. + + >>> width = 0.3 + >>> distances = np.linspace(0, 1, 200) + >>> fig, ax = plt.subplots() + >>> for name, kernel in graphs.NNGraph._kernels.items(): + ... _ = ax.plot(distances, kernel(distances / width), label=name) + >>> _ = ax.set_xlabel('distance [0, inf]') + >>> _ = ax.set_ylabel('similarity [0, 1]') + >>> _ = ax.legend(loc='upper right') + """ def __init__(self, features, standardize=False, metric='euclidean', order=3, kind='knn', k=10, radius='estimate-knn', - kernel_width=None, + kernel='gaussian', kernel_width=None, backend='scipy-ckdtree', **kwargs): + # features is stored in coords, potentially standardized + self.standardize = standardize + self.metric = metric + self.kind = kind + self.kernel = kernel + self.k = k + self.backend = backend + features = np.asanyarray(features) if features.ndim != 2: raise ValueError('features should be #vertices x dimensionality') @@ -317,23 +363,21 @@ def __init__(self, features, standardize=False, if kernel_width is None: kernel_width = np.mean(W.data) if W.nnz > 0 else np.nan - # Alternative: kernel_width = radius / 2 or radius / np.log(2). - # Users can easily do the above. - def kernel(distance, width): - return np.exp(-distance**2 / width**2) + if not callable(kernel): + try: + kernel = self._kernels[kernel] + except KeyError: + raise ValueError('Unknown kernel {}.'.format(kernel)) - W.data = kernel(W.data, kernel_width) + assert np.all(W.data >= 0), 'Distance must be in [0, inf].' + W.data = kernel(W.data / kernel_width) + if not np.all((W.data >= 0) & (W.data <= 1)): + raise ValueError('Kernel returned similarity not in [0, 1].') - # features is stored in coords, potentially standardized - self.standardize = standardize - self.metric = metric self.order = order - self.kind = kind self.radius = radius self.kernel_width = kernel_width - self.k = k - self.backend = backend super(NNGraph, self).__init__(W=W, coords=features, **params_graph) @@ -349,7 +393,58 @@ def _get_extra_repr(self): if self.radius is not None: attrs['radius'] = '{:.2e}'.format(self.radius) attrs.update({ + 'kernel': '{}'.format(self.kernel), 'kernel_width': '{:.2e}'.format(self.kernel_width), 'backend': self.backend, }) return attrs + + @staticmethod + def _kernel_rectangular(distance): + return (distance < 1).astype(np.float) + + @staticmethod + def _kernel_triangular(distance, value_at_one=0.5): + distance = value_at_one * distance + return np.maximum(1 - distance, 0) + + @staticmethod + def _kernel_exponential(distance, power=1, value_at_one=0.5): + cst = np.log(value_at_one) + return np.exp(cst * distance**power) + + @staticmethod + def _kernel_powers(distance, pow1, pow2, value_at_one=0.5): + cst = (1 - value_at_one**(1/pow2))**(1/pow1) + distance = np.clip(cst * distance, 0, 1) + return (1 - distance**pow1)**pow2 + + @staticmethod + def _kernel_logistic(distance, value_at_one=0.5): + cst = 4 / value_at_one - 2 + cst = np.log(0.5 * (cst + np.sqrt(cst**2 - 4))) + distance = cst * distance + return 4 / (np.exp(distance) + 2 + np.exp(-distance)) + + @staticmethod + def _kernel_sigmoid(distance, value_at_one=0.5): + cst = 2 / value_at_one + cst = np.log(0.5 * (cst + np.sqrt(cst**2 - 4))) + distance = cst * distance + return 2 / (np.exp(distance) + np.exp(-distance)) + + _kernels = { + 'rectangular': _kernel_rectangular.__func__, + 'triangular': _kernel_triangular.__func__, + + 'exponential': _kernel_exponential.__func__, + 'gaussian': partial(_kernel_exponential.__func__, power=2), + + 'tricube': partial(_kernel_powers.__func__, pow1=3, pow2=3), + 'triweight': partial(_kernel_powers.__func__, pow1=2, pow2=3), + 'quartic': partial(_kernel_powers.__func__, pow1=2, pow2=2), + 'epanechnikov': partial(_kernel_powers.__func__, pow1=2, pow2=1), + + 'logistic': _kernel_logistic.__func__, + 'sigmoid': _kernel_sigmoid.__func__, + } diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index dd203cb7..db245a04 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -384,7 +384,7 @@ def test_nngraph(self, n_vertices=24): angles = [0, 2 * np.pi / n_vertices] points = np.stack([np.cos(angles), np.sin(angles)], axis=1) distance = np.linalg.norm(points[0] - points[1]) - weight = np.exp(-distance**2) + weight = np.exp(-np.log(2) * distance**2) column = np.zeros(n_vertices) column[1] = weight column[-1] = weight @@ -412,6 +412,7 @@ def test_nngraph(self, n_vertices=24): self.assertRaises(ValueError, Graph, np.ones((n_vertices, 3, 4))) self.assertRaises(ValueError, Graph, data, metric='invalid') self.assertRaises(ValueError, Graph, data, kind='invalid') + self.assertRaises(ValueError, Graph, data, kernel='invalid') self.assertRaises(ValueError, Graph, data, backend='invalid') self.assertRaises(ValueError, Graph, data, kind='knn', k=0) self.assertRaises(ValueError, Graph, data, kind='knn', k=n_vertices) @@ -439,6 +440,14 @@ def test_nngraph(self, n_vertices=24): Graph(data, backend=backend, leafsize=9) self.assertRaises(ValueError, Graph, data, backend='scipy-pdist', a=0) + # Kernels. + for name, kernel in graphs.NNGraph._kernels.items(): + similarity = 0 if name == 'rectangular' else 0.5 + np.testing.assert_allclose(kernel(np.ones(10)), similarity) + np.testing.assert_allclose(kernel(np.zeros(10)), 1) + Graph(data, kernel=lambda d: d.min()/d) + self.assertRaises(ValueError, Graph, data, kernel=lambda d: 1/d) + def test_bunny(self): graphs.Bunny() From bfef54852f67d840b773359475d75cd722d20c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 23:44:05 +0100 Subject: [PATCH 063/365] nngraph: fix attributes --- pygsp/graphs/nngraphs/nngraph.py | 2 +- pygsp/tests/test_graphs.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 8b9ffa98..21d0ccd3 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -322,7 +322,7 @@ def __init__(self, features, standardize=False, radius = graph.kernel_width elif radius <= 0: raise ValueError('The radius must be greater than 0.') - k = None + self.k = None else: raise ValueError('Invalid kind "{}".'.format(kind)) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index db245a04..a36bb778 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -448,6 +448,10 @@ def test_nngraph(self, n_vertices=24): Graph(data, kernel=lambda d: d.min()/d) self.assertRaises(ValueError, Graph, data, kernel=lambda d: 1/d) + # Attributes. + self.assertEqual(Graph(data, kind='knn').radius, None) + self.assertEqual(Graph(data, kind='radius').k, None) + def test_bunny(self): graphs.Bunny() From 5c2e856491bda27d5ede0416735355c8b25a892e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 23:53:07 +0100 Subject: [PATCH 064/365] nngraph: fix intermittent test failure of nmslib --- pygsp/tests/test_graphs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a36bb778..2450fe90 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -434,7 +434,7 @@ def test_nngraph(self, n_vertices=24): Graph(data, backend='flann', algorithm='kmeans') Graph(data, backend='nmslib', method='vptree') Graph(data, backend='nmslib', index=dict(post=2)) - Graph(data, backend='nmslib', query=dict(efSearch=10)) + Graph(data, backend='nmslib', query=dict(efSearch=100)) for backend in ['scipy-kdtree', 'scipy-ckdtree']: Graph(data, backend=backend, eps=1e-2) Graph(data, backend=backend, leafsize=9) From af5aecaad83ae909ecf0e32d5cc468ab4e1918ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 24 Feb 2019 23:54:27 +0100 Subject: [PATCH 065/365] nngraph: width = radius / 2 --- pygsp/graphs/nngraphs/nngraph.py | 12 +++++++----- pygsp/tests/test_graphs.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 21d0ccd3..3a77c8aa 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -215,11 +215,10 @@ class NNGraph(Graph): An example of custom kernel is ``kernel=lambda d: d.min() / d``. kernel_width : float, optional Control the width, also known as the bandwidth, :math:`\sigma` of the - kernel by scaling the distances as ``distances / kernel_width`` before + kernel. It scales the distances as ``distances / kernel_width`` before calling the kernel function. - By default, it is set to the average of all computed distances. - When building a radius graph, it's common to set it as a function of - the radius, such as ``radius / 2``. + By default, it is set to the average of all computed distances for + ``kind='knn'`` and to half the radius for ``kind='radius'``. backend : string, optional * ``'scipy-pdist'`` uses :func:`scipy.spatial.distance.pdist` to compute pairwise distances. The method is brute force and computes @@ -362,7 +361,10 @@ def __init__(self, features, standardize=False, W = utils.symmetrize(W, method='fill') if kernel_width is None: - kernel_width = np.mean(W.data) if W.nnz > 0 else np.nan + if kind == 'knn': + kernel_width = np.mean(W.data) if W.nnz > 0 else np.nan + elif kind == 'radius': + kernel_width = radius / 2 if not callable(kernel): try: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 2450fe90..94ebe2fa 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -374,7 +374,7 @@ def test_nngraph(self, n_vertices=24): else: params['backend'] = backend if backend == 'flann': - graph = Graph(random_seed=0, **params) + graph = Graph(random_seed=40, **params) else: graph = Graph(**params) np.testing.assert_allclose(graph.W.toarray(), From 0fc8fd1b6449fca5ccd39219bc48ade0f6054778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 01:11:04 +0100 Subject: [PATCH 066/365] nngraph: doc and examples --- pygsp/graphs/nngraphs/nngraph.py | 124 +++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3a77c8aa..3a68b27e 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -175,6 +175,18 @@ class NNGraph(Graph): and standard deviation of 1 (unit variance). metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional Metric used to compute pairwise distances. + + * ``'euclidean'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_2`. + * ``'manhattan'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_1`. + * ``'minkowski'`` generalizes the above and defines distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_p` + where :math:`p` is the ``order`` of the norm. + * ``'max_dist'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_\infty = \max(x_i - x_j)`, where + the maximum is taken over the elements of the vector. + More metrics may be supported for some backends. Please refer to the documentation of the chosen backend. order : float, optional @@ -225,9 +237,10 @@ class NNGraph(Graph): all distances. That is the slowest method. * ``'scipy-kdtree'`` uses :class:`scipy.spatial.KDTree`. The method builds a k-d tree to prune the number of pairwise distances it has to - compute. + compute. That is an efficient strategy for low-dimensional spaces. * ``'scipy-ckdtree'`` uses :class:`scipy.spatial.cKDTree`. The same as ``'scipy-kdtree'`` but with C bindings, which should be faster. + That is the default. * ``'flann'`` uses the `Fast Library for Approximate Nearest Neighbors (FLANN) `_. That method is an approximation. @@ -235,29 +248,124 @@ class NNGraph(Graph): `_. That method is an approximation. It should be the fastest in high-dimensional spaces. + You can look at this `benchmark + `_ to get an idea of the + relative performance of those backends. It's nonetheless wise to run + some tests on your own data. kwargs : dict Parameters to be passed to the :class:`Graph` constructor or the backend library. Examples -------- + + Construction of a graph from a set of features. + >>> import matplotlib.pyplot as plt - >>> features = np.random.RandomState(42).uniform(size=(30, 2)) + >>> rs = np.random.RandomState(42) + >>> features = rs.uniform(size=(30, 2)) >>> G = graphs.NNGraph(features) >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=5) >>> _ = G.plot(ax=axes[1]) - Plot all the pre-defined kernels. + Radius versus knn graph. + + >>> features = rs.uniform(size=(100, 3)) + >>> fig, ax = plt.subplots() + >>> G = graphs.NNGraph(features, kind='radius', radius=0.2964) + >>> label = 'radius graph ({} edges)'.format(G.n_edges) + >>> _ = ax.hist(G.W.data, bins=20, label=label, alpha=0.5) + >>> G = graphs.NNGraph(features, kind='knn', k=6) + >>> label = 'knn graph ({} edges)'.format(G.n_edges) + >>> _ = ax.hist(G.W.data, bins=20, label=label, alpha=0.5) + >>> _ = ax.legend() + >>> _ = ax.set_title('edge weights') + + Control of the sparsity of knn and radius graphs. + + >>> features = rs.uniform(size=(100, 3)) + >>> n_edges = dict(knn=[], radius=[]) + >>> n_neighbors = np.arange(1, 100, 5) + >>> radiuses = np.arange(0.05, 1.5, 0.05) + >>> for k in n_neighbors: + ... G = graphs.NNGraph(features, kind='knn', k=k) + ... n_edges['knn'].append(G.n_edges) + >>> for radius in radiuses: + ... G = graphs.NNGraph(features, kind='radius', radius=radius) + ... n_edges['radius'].append(G.n_edges) + >>> fig, axes = plt.subplots(1, 2, sharey=True) + >>> _ = axes[0].plot(n_neighbors, n_edges['knn']) + >>> _ = axes[1].plot(radiuses, n_edges['radius']) + >>> _ = axes[0].set_ylabel('number of edges') + >>> _ = axes[0].set_xlabel('number of neighbors (knn graph)') + >>> _ = axes[1].set_xlabel('radius (radius graph)') + >>> _ = fig.suptitle('Sparsity') + + Choice of metric and the curse of dimensionality. + >>> fig, axes = plt.subplots(1, 2) + >>> for dim, ax in zip([3, 30], axes): + ... features = rs.uniform(size=(100, dim)) + ... for metric in ['euclidean', 'manhattan', 'max_dist', 'cosine']: + ... G = graphs.NNGraph(features, metric=metric, + ... backend='scipy-pdist') + ... _ = ax.hist(G.W.data, bins=20, label=metric, alpha=0.5) + ... _ = ax.legend() + ... _ = ax.set_title('edge weights, {} dimensions'.format(dim)) + + Choice of kernel. + + >>> fig, axes = plt.subplots(1, 2) >>> width = 0.3 >>> distances = np.linspace(0, 1, 200) - >>> fig, ax = plt.subplots() >>> for name, kernel in graphs.NNGraph._kernels.items(): - ... _ = ax.plot(distances, kernel(distances / width), label=name) - >>> _ = ax.set_xlabel('distance [0, inf]') - >>> _ = ax.set_ylabel('similarity [0, 1]') - >>> _ = ax.legend(loc='upper right') + ... _ = axes[0].plot(distances, kernel(distances / width), label=name) + >>> _ = axes[0].set_xlabel('distance [0, inf]') + >>> _ = axes[0].set_ylabel('similarity [0, 1]') + >>> _ = axes[0].legend(loc='upper right') + >>> features = rs.uniform(size=(100, 3)) + >>> for kernel in ['gaussian', 'triangular', 'tricube', 'exponential']: + ... G = graphs.NNGraph(features, kernel=kernel) + ... _ = axes[1].hist(G.W.data, bins=20, label=kernel, alpha=0.5) + >>> _ = axes[1].legend() + >>> _ = axes[1].set_title('edge weights') + + Choice of kernel width. + + >>> fig, axes = plt.subplots() + >>> for width in [.2, .3, .4, .6, .8, None]: + ... G = graphs.NNGraph(features, kernel_width=width) + ... label = 'width = {:.2f}'.format(G.kernel_width) + ... _ = axes.hist(G.W.data, bins=20, label=label, alpha=0.5) + >>> _ = axes.legend(loc='upper left') + >>> _ = axes.set_title('edge weights') + + Choice of backend. Compare on your data! + + >>> import time + >>> sizes = [300, 1000, 3000] + >>> dims = [3, 100] + >>> backends = ['scipy-pdist', 'scipy-kdtree', 'scipy-ckdtree', 'flann', + ... 'nmslib'] + >>> times = np.full((len(sizes), len(dims), len(backends)), np.nan) + >>> for i, size in enumerate(sizes): + ... for j, dim in enumerate(dims): + ... for k, backend in enumerate(backends): + ... if (size * dim) > 1e4 and backend == 'scipy-kdtree': + ... continue # too slow + ... features = rs.uniform(size=(size, dim)) + ... start = time.time() + ... _ = graphs.NNGraph(features, backend=backend) + ... times[i][j][k] = time.time() - start + >>> fig, axes = plt.subplots(1, 2, sharey=True) + >>> for j, (dim, ax) in enumerate(zip(dims, axes)): + ... for k, backend in enumerate(backends): + ... _ = ax.loglog(sizes, times[:, j, k], '.-', label=backend) + ... _ = ax.set_title('{} dimensions'.format(dim)) + ... _ = ax.set_xlabel('number of vertices') + >>> _ = axes[0].set_ylabel('execution time [s]') + >>> _ = axes[1].legend(loc='upper left') """ From cbb25372ba773da069940ee1071c3eaec5462549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 13:00:21 +0100 Subject: [PATCH 067/365] nngraph: update history --- doc/history.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/history.rst b/doc/history.rst index fa6cd4d6..1bc7840d 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -24,6 +24,9 @@ History * New implementation of the Sensor graph that is simpler and scales better. * A new learning module with three functions to solve standard semi-supervised classification and regression problems. +* A much improved, fixed, documented, and tested NNGraph. The user can now + select the backend and similarity kernel. The radius can be estimated and + features standardized. (PR #43) Experimental filter API (to be tested and validated): From 1da0e55094e9c8c8c894aa79ac193b053626cc95 Mon Sep 17 00:00:00 2001 From: nperraud <6399466+nperraud@users.noreply.github.com> Date: Mon, 25 Feb 2019 13:39:19 +0100 Subject: [PATCH 068/365] Update nngraph.py --- pygsp/graphs/nngraphs/nngraph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3a68b27e..4434261e 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -154,7 +154,7 @@ class NNGraph(Graph): where :math:`d(v_i, v_j)` is a distance measure between some representation (the features) of :math:`v_i` and :math:`v_j`, :math:`k` is a kernel function that transforms a distance in :math:`[0, \infty]` to a similarity - measure in :math:`[0, 1]`, and :math:`\sigma` is the kernel width. + measure generally in :math:`[0, 1]`, and :math:`\sigma` is the kernel width. For example, the features might be the 3D coordinates of points in a point cloud. Then, if ``metric='euclidean'`` and ``kernel='gaussian'`` (the @@ -163,7 +163,7 @@ class NNGraph(Graph): The similarity matrix :math:`A` is sparsified by either keeping the ``k`` closest vertices for each vertex (if ``type='knn'``), or by setting to zero - any distance greater than ``radius`` (if ``type='radius'``). + the similarity when the distance is greater than ``radius`` (if ``type='radius'``). Parameters ---------- From 17dc1c66bf222fc28a300a3b7be67854948cc63d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 04:15:24 +0100 Subject: [PATCH 069/365] nngraph: only warn for similarity > 1 --- pygsp/graphs/nngraphs/nngraph.py | 12 +++++++----- pygsp/tests/test_graphs.py | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 4434261e..cb14319f 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -11,6 +11,9 @@ from pygsp.graphs import Graph # prevent circular import in Python < 3.5 +_logger = utils.build_logger(__name__) + + def _scipy_pdist(features, metric, order, kind, k, radius, params): if params: raise ValueError('unexpected parameters {}'.format(params)) @@ -445,10 +448,9 @@ def __init__(self, features, standardize=False, if kind == 'radius': n_disconnected = np.sum(np.asarray(n_edges) == 0) if n_disconnected > 0: - logger = utils.build_logger(__name__) - logger.warning('{} vertices (out of {}) are disconnected. ' - 'Consider increasing the radius or setting ' - 'kind=knn.'.format(n_disconnected, n_vertices)) + _logger.warning('{} vertices (out of {}) are disconnected. ' + 'Consider increasing the radius or setting ' + 'kind=knn.'.format(n_disconnected, n_vertices)) value = np.empty(sum(n_edges), dtype=np.float) row = np.empty_like(value, dtype=np.int) @@ -483,7 +485,7 @@ def __init__(self, features, standardize=False, assert np.all(W.data >= 0), 'Distance must be in [0, inf].' W.data = kernel(W.data / kernel_width) if not np.all((W.data >= 0) & (W.data <= 1)): - raise ValueError('Kernel returned similarity not in [0, 1].') + _logger.warning('Kernel returned similarity not in [0, 1].') self.order = order self.radius = radius diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 94ebe2fa..8f4249bd 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -446,7 +446,9 @@ def test_nngraph(self, n_vertices=24): np.testing.assert_allclose(kernel(np.ones(10)), similarity) np.testing.assert_allclose(kernel(np.zeros(10)), 1) Graph(data, kernel=lambda d: d.min()/d) - self.assertRaises(ValueError, Graph, data, kernel=lambda d: 1/d) + if sys.version_info > (3, 4): # no assertLogs in python 2.7 + with self.assertLogs(level='WARNING'): + Graph(data, kernel=lambda d: 1/d) # Attributes. self.assertEqual(Graph(data, kind='knn').radius, None) From ad5caeecdda588d19ac88d17cd22c88e1db8c639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 00:23:31 +0100 Subject: [PATCH 070/365] show original exception if nmslib cannot be imported --- pygsp/graphs/nngraphs/nngraph.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index cb14319f..4ca8f9a2 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -124,10 +124,11 @@ def _nmslib(features, metric, order, kind, k, _, params): raise ValueError('nmslib does not support metric="minkowski".') try: import nmslib as nms - except Exception: + except Exception as e: raise ImportError('Cannot import nmslib. Choose another nearest ' - 'neighbors method or try to install it with ' - 'pip (or conda) install nmslib.') + 'neighbors backend or try to install it with ' + 'pip (or conda) install nmslib. ' + 'Original exception: {}'.format(e)) n_vertices, _ = features.shape params_index = params.pop('index', None) params_query = params.pop('query', None) From f9cc066dfb4bd01f5d9f87c684479760f25873e3 Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Mon, 22 Jul 2019 10:22:01 +0200 Subject: [PATCH 071/365] add nn support --- pygsp/_nearest_neighbor.py | 245 +++++++++++++++++++++++++++ pygsp/tests/test_nearest_neighbor.py | 46 +++++ 2 files changed, 291 insertions(+) create mode 100644 pygsp/_nearest_neighbor.py create mode 100644 pygsp/tests/test_nearest_neighbor.py diff --git a/pygsp/_nearest_neighbor.py b/pygsp/_nearest_neighbor.py new file mode 100644 index 00000000..3ae6a60d --- /dev/null +++ b/pygsp/_nearest_neighbor.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +import numpy as np +from scipy import sparse, spatial +from pygsp import utils + +def _scipy_pdist(features, metric, order, kind, k, radius, params): + if params: + raise ValueError('unexpected parameters {}'.format(params)) + metric = 'cityblock' if metric == 'manhattan' else metric + metric = 'chebyshev' if metric == 'max_dist' else metric + params = dict(metric=metric) + if metric == 'minkowski': + params['p'] = order + dist = spatial.distance.pdist(features, **params) + dist = spatial.distance.squareform(dist) + if kind == 'knn': + neighbors = np.argsort(dist)[:, :k+1] + distances = np.take_along_axis(dist, neighbors, axis=-1) + elif kind == 'radius': + distances = [] + neighbors = [] + for distance in dist: + neighbor = np.flatnonzero(distance < radius) + neighbors.append(neighbor) + distances.append(distance[neighbor]) + return neighbors, distances + + +def _scipy_kdtree(features, _, order, kind, k, radius, params): + if order is None: + raise ValueError('invalid metric for scipy-kdtree') + eps = params.pop('eps', 0) + tree = spatial.KDTree(features, **params) + params = dict(p=order, eps=eps) + if kind == 'knn': + params['k'] = k + 1 + elif kind == 'radius': + params['k'] = None + params['distance_upper_bound'] = radius + distances, neighbors = tree.query(features, **params) + return neighbors, distances + + +def _scipy_ckdtree(features, _, order, kind, k, radius, params): + if order is None: + raise ValueError('invalid metric for scipy-kdtree') + eps = params.pop('eps', 0) + tree = spatial.cKDTree(features, **params) + params = dict(p=order, eps=eps, n_jobs=-1) + if kind == 'knn': + params['k'] = k + 1 + elif kind == 'radius': + params['k'] = features.shape[0] # number of vertices + params['distance_upper_bound'] = radius + distances, neighbors = tree.query(features, **params) + if kind == 'knn': + return neighbors, distances + elif kind == 'radius': + dist = [] + neigh = [] + for distance, neighbor in zip(distances, neighbors): + mask = (distance != np.inf) + dist.append(distance[mask]) + neigh.append(neighbor[mask]) + return neigh, dist + + +def _flann(features, metric, order, kind, k, radius, params): + if metric == 'max_dist': + raise ValueError('flann gives wrong results for metric="max_dist".') + try: + import cyflann as cfl + except Exception as e: + raise ImportError('Cannot import cyflann. Choose another nearest ' + 'neighbors backend or try to install it with ' + 'pip (or conda) install cyflann. ' + 'Original exception: {}'.format(e)) + cfl.set_distance_type(metric, order=order) + index = cfl.FLANNIndex() + index.build_index(features, **params) + # I tried changing the algorithm and testing performance on huge matrices, + # but the default parameters seems to work best. + if kind == 'knn': + neighbors, distances = index.nn_index(features, k+1) + if metric == 'euclidean': + np.sqrt(distances, out=distances) + elif metric == 'minkowski': + np.power(distances, 1/order, out=distances) + elif kind == 'radius': + distances = [] + neighbors = [] + if metric == 'euclidean': + radius = radius**2 + elif metric == 'minkowski': + radius = radius**order + n_vertices, _ = features.shape + for vertex in range(n_vertices): + neighbor, distance = index.nn_radius(features[vertex, :], radius) + distances.append(distance) + neighbors.append(neighbor) + if metric == 'euclidean': + distances = list(map(np.sqrt, distances)) + elif metric == 'minkowski': + distances = list(map(lambda d: np.power(d, 1/order), distances)) + index.free_index() + return neighbors, distances + + +def _nmslib(features, metric, order, kind, k, _, params): + if kind == 'radius': + raise ValueError('nmslib does not support kind="radius".') + if metric == 'minkowski': + raise ValueError('nmslib does not support metric="minkowski".') + try: + import nmslib as nms + except Exception as e: + raise ImportError('Cannot import nmslib. Choose another nearest ' + 'neighbors backend or try to install it with ' + 'pip (or conda) install nmslib. ' + 'Original exception: {}'.format(e)) + n_vertices, _ = features.shape + params_index = params.pop('index', None) + params_query = params.pop('query', None) + metric = 'l2' if metric == 'euclidean' else metric + metric = 'l1' if metric == 'manhattan' else metric + metric = 'linf' if metric == 'max_dist' else metric + index = nms.init(space=metric, **params) + index.addDataPointBatch(features) + index.createIndex(params_index) + if params_query is not None: + index.setQueryTimeParams(params_query) + results = index.knnQueryBatch(features, k=k+1) + neighbors, distances = zip(*results) + distances = np.concatenate(distances).reshape(n_vertices, k+1) + neighbors = np.concatenate(neighbors).reshape(n_vertices, k+1) + return neighbors, distances + +def nn(features, metric='euclidean', order=2, kind='knn', k=10, radius=None, backend='scipy-ckdtree', **kwargs): + '''Find nearest neighboors. + + Parameters + ---------- + features : data numpy array + metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional + Metric used to compute pairwise distances. + + * ``'euclidean'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_2`. + * ``'manhattan'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_1`. + * ``'minkowski'`` generalizes the above and defines distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_p` + where :math:`p` is the ``order`` of the norm. + * ``'max_dist'`` defines pairwise distances as + :math:`d(v_i, v_j) = \| x_i - x_j \|_\infty = \max(x_i - x_j)`, where + the maximum is taken over the elements of the vector. + + More metrics may be supported for some backends. + Please refer to the documentation of the chosen backend. + kind : 'knn' or 'radius' (default 'knn') + k : number of nearest neighboors if 'knn' is selected + radius : radius of the search if 'radius' is slected + + order : float, optional + The order of the Minkowski distance for ``metric='minkowski'``. + backend : string, optional + * ``'scipy-pdist'`` uses :func:`scipy.spatial.distance.pdist` to + compute pairwise distances. The method is brute force and computes + all distances. That is the slowest method. + * ``'scipy-kdtree'`` uses :class:`scipy.spatial.KDTree`. The method + builds a k-d tree to prune the number of pairwise distances it has to + compute. That is an efficient strategy for low-dimensional spaces. + * ``'scipy-ckdtree'`` uses :class:`scipy.spatial.cKDTree`. The same as + ``'scipy-kdtree'`` but with C bindings, which should be faster. + That is the default. + * ``'flann'`` uses the `Fast Library for Approximate Nearest Neighbors + (FLANN) `_. That method is an + approximation. + * ``'nmslib'`` uses the `Non-Metric Space Library (NMSLIB) + `_. That method is an + approximation. It should be the fastest in high-dimensional spaces. + + You can look at this `benchmark + `_ to get an idea of the + relative performance of those backends. It's nonetheless wise to run + some tests on your own data. + ''' + if kind=='knn': + radius = None + elif kind=='radius': + k = None + else: + raise ValueError('"kind" must be "knn" or "radius"') + + _orders = { + 'euclidean': 2, + 'manhattan': 1, + 'max_dist': np.inf, + 'minkowski': order, + } + order = _orders.pop(metric, None) + try: + function = globals()['_' + backend.replace('-', '_')] + except KeyError: + raise ValueError('Invalid backend "{}".'.format(backend)) + neighbors, distances = function(features, metric, order, + kind, k, radius, kwargs) + return neighbors, distances + + +def sparse_distance_matrix(neighbors, distances, symmetrize=True, safe=False, kind = None): + '''Build a sparse distance matrix.''' + n_edges = [len(n) - 1 for n in neighbors] # remove distance to self + if safe and kind is None: + raise ValueError('Please specify "kind" to "knn" or "radius" to use the safe mode') + + if safe and kind == 'radius': + n_disconnected = np.sum(np.asarray(n_edges) == 0) + if n_disconnected > 0: + _logger.warning('{} points (out of {}) have no neighboors. ' + 'Consider increasing the radius or setting ' + 'kind=knn.'.format(n_disconnected, n_vertices)) + + value = np.empty(sum(n_edges), dtype=np.float) + row = np.empty_like(value, dtype=np.int) + col = np.empty_like(value, dtype=np.int) + start = 0 + n_vertices = len(n_edges) + for vertex in range(n_vertices): + if safe and kind == 'knn': + assert n_edges[vertex] == k + end = start + n_edges[vertex] + value[start:end] = distances[vertex][1:] + row[start:end] = np.full(n_edges[vertex], vertex) + col[start:end] = neighbors[vertex][1:] + start = end + W = sparse.csr_matrix((value, (row, col)), (n_vertices, n_vertices)) + if symmetrize: + # Enforce symmetry. May have been broken by k-NN. Checking symmetry + # with np.abs(W - W.T).sum() is as costly as the symmetrization itself. + W = utils.symmetrize(W, method='fill') + return W \ No newline at end of file diff --git a/pygsp/tests/test_nearest_neighbor.py b/pygsp/tests/test_nearest_neighbor.py new file mode 100644 index 00000000..77a6cde6 --- /dev/null +++ b/pygsp/tests/test_nearest_neighbor.py @@ -0,0 +1,46 @@ +import unittest +import numpy as np +from nn import nn + +class TestCase(unittest.TestCase): + def test_nngraph(self, n_vertices=24): + data = np.random.RandomState(42).uniform(size=(n_vertices, 3)) + metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann', 'nmslib'] + + for metric in metrics: + for kind in ['knn', 'radius']: + for backend in backends: + params = dict(features=data, metric=metric, kind=kind, radius=0.25) + ref_nn, ref_d = nn(backend='scipy-pdist', **params) + # Unsupported combinations. + if backend == 'flann' and metric == 'max_dist': + self.assertRaises(ValueError, nn, data, + metric=metric, backend=backend) + elif backend == 'nmslib' and metric == 'minkowski': + self.assertRaises(ValueError, nn, data, + metric=metric, backend=backend) + elif backend == 'nmslib' and kind == 'radius': + self.assertRaises(ValueError, nn, data, + kind=kind, backend=backend) + else: + params['backend'] = backend + if backend == 'flann': +# params['target_precision'] = 1 + other_nn, other_d = nn(random_seed=44, **params) + else: + other_nn, other_d = nn(**params) + print(kind, backend) + for a,b in zip(ref_nn, other_nn): + np.testing.assert_allclose(np.sort(a),np.sort(b), rtol=1e-5) + + for a,b in zip(ref_d, other_d): + np.testing.assert_allclose(np.sort(a),np.sort(b), rtol=1e-5) + + def test_sparse_distance_matrix(self): + data = np.random.RandomState(42).uniform(size=(24, 3)) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 29d6fa6a52dbfe26cade3a6adca1df4a5486c04f Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Mon, 22 Jul 2019 10:44:00 +0200 Subject: [PATCH 072/365] make test work --- pygsp/_nearest_neighbor.py | 4 +-- pygsp/tests/test_all.py | 2 ++ pygsp/tests/test_nearest_neighbor.py | 53 +++++++++++++++++++++------- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/pygsp/_nearest_neighbor.py b/pygsp/_nearest_neighbor.py index 3ae6a60d..daa87c60 100644 --- a/pygsp/_nearest_neighbor.py +++ b/pygsp/_nearest_neighbor.py @@ -138,7 +138,7 @@ def _nmslib(features, metric, order, kind, k, _, params): neighbors = np.concatenate(neighbors).reshape(n_vertices, k+1) return neighbors, distances -def nn(features, metric='euclidean', order=2, kind='knn', k=10, radius=None, backend='scipy-ckdtree', **kwargs): +def nearest_neighbor(features, metric='euclidean', order=2, kind='knn', k=10, radius=None, backend='scipy-ckdtree', **kwargs): '''Find nearest neighboors. Parameters @@ -212,7 +212,7 @@ def nn(features, metric='euclidean', order=2, kind='knn', k=10, radius=None, bac def sparse_distance_matrix(neighbors, distances, symmetrize=True, safe=False, kind = None): - '''Build a sparse distance matrix.''' + '''Build a sparse distance matrix from nearest neighbors''' n_edges = [len(n) - 1 for n in neighbors] # remove distance to self if safe and kind is None: raise ValueError('Please specify "kind" to "knn" or "radius" to use the safe mode') diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index 2d52a185..1fc9a55d 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -14,9 +14,11 @@ from pygsp.tests import test_docstrings from pygsp.tests import test_plotting from pygsp.tests import test_learning +from pygsp.tests import test_nearest_neighbor suites = [] +suites.append(test_nearest_neighbor.suite) suites.append(test_graphs.suite) suites.append(test_filters.suite) suites.append(test_utils.suite) diff --git a/pygsp/tests/test_nearest_neighbor.py b/pygsp/tests/test_nearest_neighbor.py index 77a6cde6..fe3f636f 100644 --- a/pygsp/tests/test_nearest_neighbor.py +++ b/pygsp/tests/test_nearest_neighbor.py @@ -1,6 +1,6 @@ import unittest import numpy as np -from nn import nn +from pygsp._nearest_neighbor import nearest_neighbor, sparse_distance_matrix class TestCase(unittest.TestCase): def test_nngraph(self, n_vertices=24): @@ -12,35 +12,62 @@ def test_nngraph(self, n_vertices=24): for kind in ['knn', 'radius']: for backend in backends: params = dict(features=data, metric=metric, kind=kind, radius=0.25) - ref_nn, ref_d = nn(backend='scipy-pdist', **params) + ref_nn, ref_d = nearest_neighbor(backend='scipy-pdist', **params) # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': - self.assertRaises(ValueError, nn, data, + self.assertRaises(ValueError, nearest_neighbor, data, metric=metric, backend=backend) elif backend == 'nmslib' and metric == 'minkowski': - self.assertRaises(ValueError, nn, data, + self.assertRaises(ValueError, nearest_neighbor, data, metric=metric, backend=backend) elif backend == 'nmslib' and kind == 'radius': - self.assertRaises(ValueError, nn, data, + self.assertRaises(ValueError, nearest_neighbor, data, kind=kind, backend=backend) else: params['backend'] = backend if backend == 'flann': -# params['target_precision'] = 1 - other_nn, other_d = nn(random_seed=44, **params) + other_nn, other_d = nearest_neighbor(random_seed=44, **params) else: - other_nn, other_d = nn(**params) + other_nn, other_d = nearest_neighbor(**params) print(kind, backend) for a,b in zip(ref_nn, other_nn): np.testing.assert_allclose(np.sort(a),np.sort(b), rtol=1e-5) for a,b in zip(ref_d, other_d): np.testing.assert_allclose(np.sort(a),np.sort(b), rtol=1e-5) - + def test_sparse_distance_matrix(self): - data = np.random.RandomState(42).uniform(size=(24, 3)) - - + data = np.random.RandomState(42).uniform(size=(200, 3)) + neighbors, distances = nearest_neighbor(data) + W = sparse_distance_matrix(neighbors, distances, symmetrize=True) + # Assert symetry + np.testing.assert_allclose(W.todense(), W.T.todense()) + # positivity + np.testing.assert_array_equal(W.todense()>=0, True) + # 0 diag + np.testing.assert_array_equal(np.diag(W.todense())==0, True) + + # Assert that it is not symmetric anymore + W = sparse_distance_matrix(neighbors, distances, symmetrize=False) + assert(np.sum(np.abs(W.todense()-W.T.todense()))>0.1) + # positivity + np.testing.assert_array_equal(W.todense()>=0, True) + # 0 diag + np.testing.assert_array_equal(np.diag(W.todense())==0, True) + # everything is used once + np.testing.assert_allclose(np.sum(W.todense()), np.sum(distances)) + + # simple test with a kernel + W = sparse_distance_matrix(neighbors, 1/(1+distances), symmetrize=True) + # Assert symetry + np.testing.assert_allclose(W.todense(), W.T.todense()) + # positivity + np.testing.assert_array_equal(W.todense()>=0, True) + # 0 diag + np.testing.assert_array_equal(np.diag(W.todense())==0, True) + + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) + if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From 280ae3cd8b9712c4664dbb787d98f96274f9a132 Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Mon, 22 Jul 2019 11:06:48 +0200 Subject: [PATCH 073/365] fix tests --- pygsp/tests/test_graphs.py | 2 +- pygsp/tests/test_nearest_neighbor.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8f4249bd..ad7e27a8 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -358,7 +358,7 @@ def test_nngraph(self, n_vertices=24): for metric in metrics: for kind in ['knn', 'radius']: - params = dict(features=data, metric=metric, kind=kind) + params = dict(features=data, metric=metric, kind=kind, k=4) ref = Graph(backend='scipy-pdist', **params) for backend in backends: # Unsupported combinations. diff --git a/pygsp/tests/test_nearest_neighbor.py b/pygsp/tests/test_nearest_neighbor.py index fe3f636f..feaa7509 100644 --- a/pygsp/tests/test_nearest_neighbor.py +++ b/pygsp/tests/test_nearest_neighbor.py @@ -68,6 +68,3 @@ def test_sparse_distance_matrix(self): suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) - -if __name__ == '__main__': - unittest.main() From 0b1242b5d666eb3949b9bf50cec33efccf74f81a Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Mon, 22 Jul 2019 11:37:37 +0200 Subject: [PATCH 074/365] make k=4 to pass tests --- pygsp/tests/test_nearest_neighbor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/tests/test_nearest_neighbor.py b/pygsp/tests/test_nearest_neighbor.py index feaa7509..5d734153 100644 --- a/pygsp/tests/test_nearest_neighbor.py +++ b/pygsp/tests/test_nearest_neighbor.py @@ -11,7 +11,7 @@ def test_nngraph(self, n_vertices=24): for metric in metrics: for kind in ['knn', 'radius']: for backend in backends: - params = dict(features=data, metric=metric, kind=kind, radius=0.25) + params = dict(features=data, metric=metric, kind=kind, radius=0.25, k=4) ref_nn, ref_d = nearest_neighbor(backend='scipy-pdist', **params) # Unsupported combinations. if backend == 'flann' and metric == 'max_dist': From 9a8400fcdfba5dac5d9287ac107a8c7e0707c81f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 23 Aug 2018 23:48:44 +0800 Subject: [PATCH 075/365] Export to Networkx & Graph Tool Export from PyGSP to external libraries. Work in progress, the exports methods haven't been tested yet --- pygsp/graphs/graph.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 39333c95..2e021436 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -122,6 +122,23 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) + def to_networkx(self): + r"""Doc TODO""" + import networkx as nx + return nx.from_scipy_sparse_matrix(self.W) + + def to_graphtool(self, directed=False): + r"""Doc TODO""" + ##from graph_tool.all import * + import graph_tool + g = graph_tool.Graph(directed=directed) + nonzero = self.W.nonzero() + g.add_edge_list(np.transpose(nonzero)) + edge_weight = g.new_edge_property("double") + edge_weight.a = np.squeeze(np.array(self.W[nonzero])) + g.edge_properties["weight"] = edge_weight + return g + def check_weights(self): r"""Check the characteristics of the weights matrix. From 9a5808b14b2842fb5e6dee3d379319fe6c509458 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 29 Aug 2018 23:56:00 +0800 Subject: [PATCH 076/365] Adding import from Networkx and graph tool lib change tempoary implemented in @classmethods and still not tested. Futhermore some optimization could be done especialy on the import from graph tool --- pygsp/graphs/graph.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 2e021436..25ba3171 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -131,7 +131,7 @@ def to_graphtool(self, directed=False): r"""Doc TODO""" ##from graph_tool.all import * import graph_tool - g = graph_tool.Graph(directed=directed) + g = graph_tool.Graph(directed=directed) #TODO check for undirected graph nonzero = self.W.nonzero() g.add_edge_list(np.transpose(nonzero)) edge_weight = g.new_edge_property("double") @@ -139,6 +139,31 @@ def to_graphtool(self, directed=False): g.edge_properties["weight"] = edge_weight return g + @classmethod + def from_networkx(cls, graph_nx): + r"""Doc TODO""" + import networkx as nx + A = nx.to_scipy_sparse_matrix(graph_nx) + G = cls(A) + return G + + @classmethod + def from_graphtool(cls, graph_gt): + r"""Doc TODO""" + nb_vertex = len(graph_gt.get_vertices()) + edge_weight = np.ones(nb_vertex) + W = np.zeros(shape=(nb_vertex, nb_vertex)) + + props_names = graph_gt.edge_properties.keys() + if "weight" in props_names: + prop = graph_gt.edge_properties["weight"] + edge_weight = prop.get_array() + + for e in graph_gt.get_edges(): + W[e[0], e[1]] = edge_weight[e[2]] + return cls(W) + + def check_weights(self): r"""Check the characteristics of the weights matrix. From 8f936b3a0309925d6e13456394e9a9c970cde7ca Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Fri, 7 Sep 2018 00:04:03 +0800 Subject: [PATCH 077/365] Basics test for import exports The test create a graph in pygsp, convert it into the lib and load it back A random graph from the lib si created as well and loaded then exported again --- pygsp/tests/test_graphs.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ad7e27a8..ac2e1227 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -560,5 +560,47 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) + def test_networkx_import_export(self): + import networkx as nx + bunny = graphs.Bunny() + n_bunny = bunny.to_networkx() + new_bunny = graphs.Graph.from_networkx(n_bunny) + np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) + + n_g = nx.gnm_random_graph(100, 50) + + new_n_g = graphs.Graph.from_networkx(n_g).to_networkx() + + assert nx.is_isomorphic(n_g, new_n_g) + np.testing.assert_array_equal(nx.adjacency_matrix(n_g).todense(), + nx.adjacency_matrix(new_n_g).todense()) + + def test_graphtool_import_export(self): + import graph_tool as gt + bunny = graphs.Bunny() + gt_bunny = bunny.to_graphtool() + new_bunny = graphs.Graph.from_graphtool(gt_bunny) + np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) + + g = gt.Graph() + g.add_vertex(100) + # insert some random links + for s,t in zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100)): + g.add_edge(g.vertex(s), g.vertex(t)) + # this assigns random values to the vertex properties + vprop_double = g.new_vertex_property("double") + vprop_double.get_array()[:] = np.random.random(g.num_vertices()) + + new_g = graphs.Graph.from_graphtool(g).to_graphtool() + key = lambda e: str(e.source()) + ":" + str(e.target()) + assert len([e for e in g.edges()]) == len([e for e in new_g.edges()]),\ + "the number of edge does not correspond" + #TODO check if in graph tool its normal to have multiple edges between two vertex + for e1,e2 in zip(sorted(g.edges(), key= key), sorted(new_g.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g.vertices(), new_g.vertices()): + assert v1 == v2 suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 30d0386972f847335a51706278704303dab82493 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Fri, 7 Sep 2018 00:05:53 +0800 Subject: [PATCH 078/365] Documentation The documentation for the import and export methods have been started --- pygsp/graphs/graph.py | 69 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 25ba3171..e47759fa 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -123,33 +123,80 @@ def __repr__(self, limit=None): return '{}({})'.format(self.__class__.__name__, s[:-2]) def to_networkx(self): - r"""Doc TODO""" + r"""Export the graph to an `Networkx `_ object + + Returns + ------- + g_nx : `Graph `_ + """ import networkx as nx return nx.from_scipy_sparse_matrix(self.W) - def to_graphtool(self, directed=False): - r"""Doc TODO""" + def to_graphtool(self, edge_prop_name='weight', directed=True): + r"""Export the graph to an `Graph tool `_ object + The weights of the graph are stored in a `property maps `_ + of type double + + + Parameters + ---------- + edge_prop_name : string + Name of the `property `_. + By default it is set to `weight` + directed : bool + Indicate if the graph is `directed `_ + + Returns + ------- + g_gt : `Graph `_ + """ ##from graph_tool.all import * import graph_tool - g = graph_tool.Graph(directed=directed) #TODO check for undirected graph + g_gt = graph_tool.Graph(directed=directed) #TODO check for undirected graph nonzero = self.W.nonzero() - g.add_edge_list(np.transpose(nonzero)) - edge_weight = g.new_edge_property("double") + g_gt.add_edge_list(np.transpose(nonzero)) + edge_weight = g_gt.new_edge_property('double') edge_weight.a = np.squeeze(np.array(self.W[nonzero])) - g.edge_properties["weight"] = edge_weight - return g + g_gt.edge_properties[edge_prop_name] = edge_weight + return g_gt @classmethod def from_networkx(cls, graph_nx): - r"""Doc TODO""" + r"""Build a graph from a Networkx object + + Parameters + ---------- + graph_nx : Graph + A netowrkx instance of a graph + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + """ + import networkx as nx A = nx.to_scipy_sparse_matrix(graph_nx) G = cls(A) return G @classmethod - def from_graphtool(cls, graph_gt): - r"""Doc TODO""" + def from_graphtool(cls, graph_gt, edge_prop_name='weight'): + r"""Build a graph from a graph tool object. + + Parameters + ---------- + graph_gt : Graph + Graph tool object + edge_prop_name : string + Name of the `property `_ + to be loaded as weight for the graph + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + The weight of the graph are loaded from the edge property named ``edge_prop_name`` + + """ nb_vertex = len(graph_gt.get_vertices()) edge_weight = np.ones(nb_vertex) W = np.zeros(shape=(nb_vertex, nb_vertex)) From 955068bdae3fc3fc69db38820d5ef9e21f2f2d7e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 10 Sep 2018 00:08:37 +0800 Subject: [PATCH 079/365] Save and Load Work in progress. not tested but the 'gml' format has been implemented --- pygsp/graphs/graph.py | 84 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e47759fa..3c5462f2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -208,7 +208,89 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): for e in graph_gt.get_edges(): W[e[0], e[1]] = edge_weight[e[2]] - return cls(W) + return cls(W) + + @classmethod + def load(cls, path, fmt='auto', lib='networkx'): + r"""Load a graph from a file using networkx for import. + The format is guessed from path, or can be specified by fmt + + Parameters + ---------- + path : String + Where the file is located on the disk. + fmt : String + Format in which the graph is encoded. Currently supported format are: + GML, gpickle. + lib : String + Python library used in background to load the graph. + Supported library are networkx and graph_tool + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + + """ + if fmt == 'auto': + fmt = path.split('.')[-1] + + exec('import ' + lib) + + err = NotImplementedError('{} can not be load with {}. \ + Try another background library'.format(fmt, lib)) + + if fmt == 'gml': + if lib == 'networkx': + g = networkx.read_gml(path) + return from_networkx(g) + if lib == 'graph_tool': + g = graph_tool.load_graph(path, fmt=fmt) + return from_graphtool(g) + raise err + + if fmt in ['gpickle', 'p', 'pkl', 'pickle']: + if lib == 'networkx': + g = networkx.read_gpickle(path) + return from_networkx(g) + raise err + + raise NotImplementedError('the format {} is not suported'.format(fmt)) + + def save(self, path, fmt='auto', lib='networkx'): + r"""Save the graph into a file + + Parameters + ---------- + path : String + Where to save file on the disk. + fmt : String + Format in which the graph will be encoded. The format is guessed from + the `path` extention when fmt is set to 'auto' + Currently supported format are: + GML, gpickle. + lib : String + Python library used in background to save the graph. + Supported library are networkx and graph_tool + + + """ + if fmt == 'auto': + fmt = path.split('.')[-1] + + exec('import ' + lib) + + if fmt == 'gml': + if lib == 'networkx': + g = to_networkx() + networkx.write_gml(g, path) + return + if lib == 'graph_tool': + g = to_graphtool() + g.save(path, fmt=fmt) + raise err + + raise NotImplementedError('the format {} is not suported'.format(fmt)) + def check_weights(self): From b1336d70f62287c845f75c923b6f2d512728bdcf Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 12 Sep 2018 00:23:00 +0800 Subject: [PATCH 080/365] Import from graph tool by summing over multiple edge --- pygsp/graphs/graph.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 3c5462f2..e6f060ed 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -4,7 +4,8 @@ import numpy as np from scipy import sparse - +from itertools import groupby +import warnings from pygsp import utils from . import fourier, difference # prevent circular import in Python < 3.5 @@ -202,12 +203,20 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): W = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() - if "weight" in props_names: - prop = graph_gt.edge_properties["weight"] + + if edge_prop_name in props_names: + prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() - - for e in graph_gt.get_edges(): - W[e[0], e[1]] = edge_weight[e[2]] + else: + warnings.warn("""{} property not found in the graph, \ + weights of 1 for the edges are set""".format(edge_prop_name)) + edge_weight = np.ones(len(g.edges)) + # merging multi-edge + merged_edge_weight = [] + for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): + merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) + for e in merged_edge_weight: + W[e[0], e[1]] = e[2] return cls(W) @classmethod From 1104f287d7a5cd7527fc846621c42a10cda519ff Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 12 Sep 2018 00:23:53 +0800 Subject: [PATCH 081/365] Fix bug when creating a random graph_tool graph --- pygsp/tests/test_graphs.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ac2e1227..abfb1857 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -582,15 +582,22 @@ def test_graphtool_import_export(self): new_bunny = graphs.Graph.from_graphtool(gt_bunny) np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) + #create a random graphTool graph g = gt.Graph() g.add_vertex(100) # insert some random links - for s,t in zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100)): + eprop_double = g.new_edge_property("double") + for s, t in zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100)): g.add_edge(g.vertex(s), g.vertex(t)) - # this assigns random values to the vertex properties - vprop_double = g.new_vertex_property("double") + + for e in g.edges(): + eprop_double[e] = random.random() + g.edge_properties["weight"] = eprop_double + # this assigns random values to the vertex properties (this is a signal) + vprop_double = g.new_vertex_property("double") vprop_double.get_array()[:] = np.random.random(g.num_vertices()) + g.vertex_properties["signal"] = vprop_double new_g = graphs.Graph.from_graphtool(g).to_graphtool() key = lambda e: str(e.source()) + ":" + str(e.target()) From 9dbef17053b7963bd06596b4c8acaf550475e246 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 09:51:42 +0800 Subject: [PATCH 082/365] custom aggragation function implemented for merging multi-edges --- pygsp/graphs/graph.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e6f060ed..7d08cfff 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -181,7 +181,7 @@ def from_networkx(cls, graph_nx): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight'): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): r"""Build a graph from a graph tool object. Parameters @@ -191,6 +191,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): edge_prop_name : string Name of the `property `_ to be loaded as weight for the graph + aggr_fun : function + When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the + edges. By default the sum is taken. Returns ------- @@ -214,7 +217,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) + merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] return cls(W) From 4d680afb97c6bc4ee40efe10a20285b20c5cfd8b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 11:40:31 +0800 Subject: [PATCH 083/365] Export signal to networkx & graph_tool --- pygsp/graphs/graph.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7d08cfff..0e05b6e8 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -50,6 +50,8 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): is None. plotting : dict plotting parameters. + signals : dict (String -> numpy.array) + Signals attached to the graph. Examples -------- @@ -131,14 +133,18 @@ def to_networkx(self): g_nx : `Graph `_ """ import networkx as nx - return nx.from_scipy_sparse_matrix(self.W) + g = nx.from_scipy_sparse_matrix(self.W) + for key in self.signals: + dic_signal = { i : self.signals[key][i] for i in range(0, len(self.signals[key]) ) } + nx.set_node_attributes(g, dic_signal, key) + return g def to_graphtool(self, edge_prop_name='weight', directed=True): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double + WARNING: The edges and vertex property will be converted into double type - Parameters ---------- edge_prop_name : string @@ -159,6 +165,10 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): edge_weight = g_gt.new_edge_property('double') edge_weight.a = np.squeeze(np.array(self.W[nonzero])) g_gt.edge_properties[edge_prop_name] = edge_weight + for key in self.signals: + vprop_double = g_gt.new_vertex_property("double") + vprop_double.get_array()[:] = self.signals[key] + g_gt.vertex_properties[key] = vprop_double return g_gt @classmethod @@ -303,7 +313,19 @@ def save(self, path, fmt='auto', lib='networkx'): raise NotImplementedError('the format {} is not suported'.format(fmt)) + def set_signal(self, signal, signal_name): + r""" + Add or modify a signal to the graph + Parameters + ---------- + signal : numpy.array + An array maping from node to his value. For example the value of the singal at node i is signal[i] + signal_name : String + Name associated to the signal. + """ + assert len(signal) == self.N, "A value must be attached to every vertex in the graph" + self.signals[signal_name] = np.array(signal) def check_weights(self): r"""Check the characteristics of the weights matrix. From cd31ffb288d249850c7ca61e3fca6fd866159190 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:33:40 +0800 Subject: [PATCH 084/365] Import with signals from networkx --- pygsp/graphs/graph.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0e05b6e8..d9b2f457 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -172,13 +172,15 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): return g_gt @classmethod - def from_networkx(cls, graph_nx): + def from_networkx(cls, graph_nx, singals_names = []): r"""Build a graph from a Networkx object Parameters ---------- graph_nx : Graph A netowrkx instance of a graph + singals_names : list[String] + List of signal names to import from Returns ------- @@ -186,8 +188,15 @@ def from_networkx(cls, graph_nx): """ import networkx as nx - A = nx.to_scipy_sparse_matrix(graph_nx) + nodelist = graph_nx.nodes() + A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) + for s_name in singals_names: + s_dict = nx.get_node_attributes(graph_nx, s_name) + if len(s_dict.keys()) == 0: + raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) + s_value = np.array([s_dict[n] for n in nodelist]) #force the order to be same as for the agency matrix + G.set_signal(s_value, s_name) return G @classmethod From 61b16a94b32fe2605cfa5bdabaeb7f6862873de6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:34:24 +0800 Subject: [PATCH 085/365] graphtool import fix bug when a edge property not present --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d9b2f457..65abe98b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -232,7 +232,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(len(g.edges)) + edge_weight = np.ones(len(graph_gt.edges())) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): From caa2a5faa408c52cb0566162e0b91d15b49eeef1 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:53:10 +0800 Subject: [PATCH 086/365] Import signal from graph_tool --- pygsp/graphs/graph.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 65abe98b..aed25b48 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -180,7 +180,7 @@ def from_networkx(cls, graph_nx, singals_names = []): graph_nx : Graph A netowrkx instance of a graph singals_names : list[String] - List of signal names to import from + List of signals names to import from the networkx graph Returns ------- @@ -200,7 +200,7 @@ def from_networkx(cls, graph_nx, singals_names = []): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals_names = []): r"""Build a graph from a graph tool object. Parameters @@ -213,7 +213,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. - + singals_names : list[String] or 'all' + List of signals names to import from the graph_tool graph or if set to 'all' import all signal present + in the graph Returns ------- g : :class:`~pygsp.graphs.Graph` @@ -239,7 +241,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] - return cls(W) + g = cls(W) + #Adding signals + if singals_names == 'all': + singals_names == graph_gt.vertex_properties.keys() + for s_name in singals_names: + s = np.array([graph_gt.vertex_properties[v] for v in graph_gt.vertices()]) + g.set_signal(s, s_name) + return g @classmethod def load(cls, path, fmt='auto', lib='networkx'): From ebd9765a7acbc891d4e60f3f6e64cfbc5cd1907f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 00:38:18 +0800 Subject: [PATCH 087/365] Fix some typos that were not covered by tests --- pygsp/graphs/graph.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index aed25b48..750c1765 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -174,6 +174,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): @classmethod def from_networkx(cls, graph_nx, singals_names = []): r"""Build a graph from a Networkx object + The nodes are ordered according to methode `nodes()` from networkx Parameters ---------- @@ -209,7 +210,11 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals Graph tool object edge_prop_name : string Name of the `property `_ - to be loaded as weight for the graph + to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. + On the other hand if the property is found but not set for a specific edge the weight of zero will be set + therefore for single edge this will result in a none existing edge. If you want to set to a default value please + use `set_value`_ + from the graph_tool object. aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. @@ -234,7 +239,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(len(graph_gt.edges())) + edge_weight = np.ones(graph_gt.edge_index_range) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): @@ -244,10 +249,13 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals g = cls(W) #Adding signals if singals_names == 'all': - singals_names == graph_gt.vertex_properties.keys() + singals_names = graph_gt.vertex_properties.keys() for s_name in singals_names: - s = np.array([graph_gt.vertex_properties[v] for v in graph_gt.vertices()]) - g.set_signal(s, s_name) + if s_name in graph_gt.vertex_properties.keys(): + s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) + g.set_signal(s, s_name) + else: + warnings.warn("{} was not found in the graph_tool graph".format(s_name)) return g @classmethod @@ -274,7 +282,10 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - exec('import ' + lib) + if lib == 'networkx': + import networkx + if lib == 'graph_tool': + import graph_tool err = NotImplementedError('{} can not be load with {}. \ Try another background library'.format(fmt, lib)) @@ -282,16 +293,16 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'gml': if lib == 'networkx': g = networkx.read_gml(path) - return from_networkx(g) + return cls.from_networkx(g) if lib == 'graph_tool': g = graph_tool.load_graph(path, fmt=fmt) - return from_graphtool(g) + return cls.from_graphtool(g) raise err if fmt in ['gpickle', 'p', 'pkl', 'pickle']: if lib == 'networkx': g = networkx.read_gpickle(path) - return from_networkx(g) + return cls.from_networkx(g) raise err raise NotImplementedError('the format {} is not suported'.format(fmt)) @@ -317,15 +328,20 @@ def save(self, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - exec('import ' + lib) + if lib == 'networkx': + import networkx + if lib == 'graph_tool': + import graph_tool + err = NotImplementedError('{} can not be save with {}. \ + Try another background library'.format(fmt, lib)) if fmt == 'gml': if lib == 'networkx': - g = to_networkx() + g = self.to_networkx() networkx.write_gml(g, path) return if lib == 'graph_tool': - g = to_graphtool() + g = self.to_graphtool() g.save(path, fmt=fmt) raise err From ca2a1641e929f8d6cf37e9ebbe3702b48f130878 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 00:38:57 +0800 Subject: [PATCH 088/365] Move tests for import and export into a new file --- pygsp/tests/test_graphs.py | 50 --------- pygsp/tests/test_import_export.py | 170 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 pygsp/tests/test_import_export.py diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index abfb1857..f828d678 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -560,54 +560,4 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) - def test_networkx_import_export(self): - import networkx as nx - bunny = graphs.Bunny() - n_bunny = bunny.to_networkx() - new_bunny = graphs.Graph.from_networkx(n_bunny) - np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) - - n_g = nx.gnm_random_graph(100, 50) - - new_n_g = graphs.Graph.from_networkx(n_g).to_networkx() - - assert nx.is_isomorphic(n_g, new_n_g) - np.testing.assert_array_equal(nx.adjacency_matrix(n_g).todense(), - nx.adjacency_matrix(new_n_g).todense()) - - def test_graphtool_import_export(self): - import graph_tool as gt - bunny = graphs.Bunny() - gt_bunny = bunny.to_graphtool() - new_bunny = graphs.Graph.from_graphtool(gt_bunny) - np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) - - #create a random graphTool graph - g = gt.Graph() - g.add_vertex(100) - # insert some random links - eprop_double = g.new_edge_property("double") - for s, t in zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100)): - g.add_edge(g.vertex(s), g.vertex(t)) - - for e in g.edges(): - eprop_double[e] = random.random() - g.edge_properties["weight"] = eprop_double - # this assigns random values to the vertex properties (this is a signal) - vprop_double = g.new_vertex_property("double") - vprop_double.get_array()[:] = np.random.random(g.num_vertices()) - g.vertex_properties["signal"] = vprop_double - - new_g = graphs.Graph.from_graphtool(g).to_graphtool() - key = lambda e: str(e.source()) + ":" + str(e.target()) - assert len([e for e in g.edges()]) == len([e for e in new_g.edges()]),\ - "the number of edge does not correspond" - #TODO check if in graph tool its normal to have multiple edges between two vertex - for e1,e2 in zip(sorted(g.edges(), key= key), sorted(new_g.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() - for v1, v2 in zip(g.vertices(), new_g.vertices()): - assert v1 == v2 - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_import_export.py b/pygsp/tests/test_import_export.py new file mode 100644 index 00000000..fb5765eb --- /dev/null +++ b/pygsp/tests/test_import_export.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- + +""" +Test suite for the Import and Export functionality inside the graphs module of the pygsp package. + +""" + +import unittest + +import numpy as np +import networkx as nx +import graph_tool as gt +import random + +from pygsp import graphs + +class TestCase(unittest.TestCase): + + def test_networkx_export_import(self): + #Export to networkx and reimport to PyGSP + + #Exporting the Bunny graph + g = graphs.Bunny() + g_nx = g.to_networkx() + g2 = graphs.Graph.from_networkx(g_nx) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + def test_networkx_import_export(self): + #Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + g = graphs.Graph.from_networkx(g_nx).to_networkx() + + assert nx.is_isomorphic(g_nx, g) + np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), + nx.adjacency_matrix(g).todense()) + + def test_graphtool_export_import(self): + #Export to graph tool and reimport to PyGSP directly + #The exported graph is a simple one without an associated Signal + g = graphs.Bunny() + g_gt = g.to_graphtool() + g2 = graphs.Graph.from_graphtool(g_gt) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + + def test_graphtool_multiedge_import(self): + #Manualy create a graph with multiple edges + g_gt = gt.Graph() + g_gt.add_vertex(10) + #connect edge (3,6) three times + for i in range(3): + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g = graphs.Graph.from_graphtool(g_gt) + assert g.W[3,6] == 3.0 + + #test custom aggregator function + g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g2.W[3,6] == 1.0 + + eprop_double = g_gt.new_edge_property("double") + + #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3,6, all_edges=True) + eprop_double[e[0]] = 8.0 + eprop_double[e[1]] = 1.0 + + g_gt.edge_properties["weight"] = eprop_double + g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g3.W[3,6] == 3.0 + + def test_graphtool_import_export(self): + # Import to PyGSP and export again to graph tool directly + # create a random graphTool graph that does not contain multiple edges and no signal + g_gt = gt.Graph() + g_gt.add_vertex(100) + + # insert single random links + eprop_double = g_gt.new_edge_property("double") + for s, t in set(zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100))): + g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + + for e in g_gt.edges(): + eprop_double[e] = random.random() + g_gt.edge_properties["weight"] = eprop_double + + g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + + assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ + "the number of edge does not correspond" + + key = lambda e: str(e.source()) + ":" + str(e.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + assert v1 == v2 + + def test_networkx_singal_export(self): + logo = graphs.Logo() + s = np.random.random(logo.N) + s2 = np.random.random(logo.N) + logo.set_signal(s, "signal1") + logo.set_signal(s2, "signal2") + logo_nx = logo.to_networkx() + for i in range(50): + # Randomly check the signal of 50 nodes to see if they are the same + rd_node = np.random.randint(logo.N) + assert logo_nx.node[rd_node]["signal1"] == s[rd_node] + assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + + def test_graphtool_signal_export(self): + g = graphs.Logo() + s = np.random.random(g.N) + s2 = np.random.random(g.N) + g.set_signal(s, "signal1") + g.set_signal(s2, "signal2") + g_gt = g.to_graphtool() + #Check the signals on all nodes + for i, v in enumerate(g_gt.vertices()): + assert g_gt.vertex_properties["signal1"][v] == s[i] + assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): + g_gt = gt.Graph() + g_gt.add_vertex(10) + + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) + + vprop_double = g_gt.new_vertex_property("double") + + vprop_double[g_gt.vertex(0)] = 5 + vprop_double[g_gt.vertex(1)] = -3 + vprop_double[g_gt.vertex(2)] = 2.4 + + g_gt.vertex_properties["signal"] = vprop_double + g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + assert g.signals["signal"][0] == 5.0 + assert g.signals["signal"][1] == -3.0 + assert g.signals["signal"][2] == 2.4 + + def test_networkx_singal_import(self): + g_nx = nx.Graph() + g_nx.add_edge(3,4) + g_nx.add_edge(2,4) + g_nx.add_edge(3,5) + print(list(g_nx.node)[0]) + dic_signal = { + 2 : 4.0, + 3 : 5.0, + 4 : 3.3, + 5 : 2.3 + } + + nx.set_node_attributes(g_nx, dic_signal, "signal1") + g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + + nodes_mapping = list(g_nx.node) + for i in range(len(nodes_mapping)): + assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + + + def test_save_load(self): + g = graphs.Bunny() + g.save("bunny.gml") + g2 = graphs.Graph.load("bunny.gml") + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) \ No newline at end of file From 3dfff00492d3178cb96115a0a6a701442e22e760 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 22:08:04 +0800 Subject: [PATCH 089/365] Cleaning code and correct the indentation for of the doc --- pygsp/graphs/graph.py | 110 ++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 750c1765..85e46032 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -159,7 +159,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): """ ##from graph_tool.all import * import graph_tool - g_gt = graph_tool.Graph(directed=directed) #TODO check for undirected graph + g_gt = graph_tool.Graph(directed=directed) nonzero = self.W.nonzero() g_gt.add_edge_list(np.transpose(nonzero)) edge_weight = g_gt.new_edge_property('double') @@ -187,16 +187,18 @@ def from_networkx(cls, graph_nx, singals_names = []): ------- g : :class:`~pygsp.graphs.Graph` """ - import networkx as nx + #keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) + #Adding the signals for s_name in singals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - s_value = np.array([s_dict[n] for n in nodelist]) #force the order to be same as for the agency matrix + #The signal is set to zero for node not present in the networkx signal + s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) G.set_signal(s_value, s_name) return G @@ -213,7 +215,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please - use `set_value`_ + use `set_value `_ from the graph_tool object. aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the @@ -221,6 +223,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals singals_names : list[String] or 'all' List of signals names to import from the graph_tool graph or if set to 'all' import all signal present in the graph + Returns ------- g : :class:`~pygsp.graphs.Graph` @@ -228,7 +231,6 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals """ nb_vertex = len(graph_gt.get_vertices()) - edge_weight = np.ones(nb_vertex) W = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -239,7 +241,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(graph_gt.edge_index_range) + edge_weight = np.ones(nb_vertex) + # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): @@ -247,6 +250,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals for e in merged_edge_weight: W[e[0], e[1]] = e[2] g = cls(W) + #Adding signals if singals_names == 'all': singals_names = graph_gt.vertex_properties.keys() @@ -265,14 +269,14 @@ def load(cls, path, fmt='auto', lib='networkx'): Parameters ---------- - path : String - Where the file is located on the disk. - fmt : String - Format in which the graph is encoded. Currently supported format are: - GML, gpickle. - lib : String - Python library used in background to load the graph. - Supported library are networkx and graph_tool + path : String + Where the file is located on the disk. + fmt : String + Format in which the graph is encoded. Currently supported format are: + GML and gpickle. + lib : String + Python library used in background to load the graph. + Supported library are networkx and graph_tool Returns ------- @@ -282,29 +286,23 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - if lib == 'networkx': - import networkx - if lib == 'graph_tool': - import graph_tool - err = NotImplementedError('{} can not be load with {}. \ - Try another background library'.format(fmt, lib)) + Try another background library'.format(fmt, lib)) - if fmt == 'gml': - if lib == 'networkx': + if lib == 'networkx': + import networkx + if fmt == 'gml': g = networkx.read_gml(path) return cls.from_networkx(g) - if lib == 'graph_tool': - g = graph_tool.load_graph(path, fmt=fmt) - return cls.from_graphtool(g) - raise err - - if fmt in ['gpickle', 'p', 'pkl', 'pickle']: - if lib == 'networkx': + if fmt in ['gpickle', 'p', 'pkl', 'pickle']: g = networkx.read_gpickle(path) return cls.from_networkx(g) raise err - + if lib == 'graph_tool': + import graph_tool + g = graph_tool.load_graph(path, fmt=fmt) + return cls.from_graphtool(g) + raise NotImplementedError('the format {} is not suported'.format(fmt)) def save(self, path, fmt='auto', lib='networkx'): @@ -312,39 +310,37 @@ def save(self, path, fmt='auto', lib='networkx'): Parameters ---------- - path : String - Where to save file on the disk. - fmt : String - Format in which the graph will be encoded. The format is guessed from - the `path` extention when fmt is set to 'auto' - Currently supported format are: - GML, gpickle. - lib : String - Python library used in background to save the graph. - Supported library are networkx and graph_tool - - + path : String + Where to save file on the disk. + fmt : String + Format in which the graph will be encoded. The format is guessed from + the `path` extention when fmt is set to 'auto' + Currently supported format are: + GML and gpickle. + lib : String + Python library used in background to save the graph. + Supported library are networkx and graph_tool """ if fmt == 'auto': fmt = path.split('.')[-1] - if lib == 'networkx': - import networkx - if lib == 'graph_tool': - import graph_tool - err = NotImplementedError('{} can not be save with {}. \ Try another background library'.format(fmt, lib)) - if fmt == 'gml': - if lib == 'networkx': + + if lib == 'networkx': + import networkx + if fmt == 'gml': g = self.to_networkx() networkx.write_gml(g, path) return - if lib == 'graph_tool': - g = self.to_graphtool() - g.save(path, fmt=fmt) raise err - + + if lib == 'graph_tool': + import graph_tool + g = self.to_graphtool() + g.save(path, fmt=fmt) + return + raise NotImplementedError('the format {} is not suported'.format(fmt)) def set_signal(self, signal, signal_name): @@ -353,10 +349,10 @@ def set_signal(self, signal, signal_name): Parameters ---------- - signal : numpy.array - An array maping from node to his value. For example the value of the singal at node i is signal[i] - signal_name : String - Name associated to the signal. + signal : numpy.array + An array maping from node to his value. For example the value of the singal at node i is signal[i] + signal_name : String + Name associated to the signal. """ assert len(signal) == self.N, "A value must be attached to every vertex in the graph" self.signals[signal_name] = np.array(signal) From 28055672e3f51329fff7fd109701614ab23251b1 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 23 Sep 2018 14:20:07 +0800 Subject: [PATCH 090/365] Adding test for import export --- pygsp/tests/test_graphs.py | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index f828d678..4e7c8b6a 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -561,3 +561,158 @@ def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) + +class TestCaseImportExport(unittest.TestCase): + + def test_networkx_export_import(self): + #Export to networkx and reimport to PyGSP + + #Exporting the Bunny graph + g = graphs.Bunny() + g_nx = g.to_networkx() + g2 = graphs.Graph.from_networkx(g_nx) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + def test_networkx_import_export(self): + #Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + g = graphs.Graph.from_networkx(g_nx).to_networkx() + + assert nx.is_isomorphic(g_nx, g) + np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), + nx.adjacency_matrix(g).todense()) + + def test_graphtool_export_import(self): + #Export to graph tool and reimport to PyGSP directly + #The exported graph is a simple one without an associated Signal + g = graphs.Bunny() + g_gt = g.to_graphtool() + g2 = graphs.Graph.from_graphtool(g_gt) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + + def test_graphtool_multiedge_import(self): + #Manualy create a graph with multiple edges + g_gt = gt.Graph() + g_gt.add_vertex(10) + #connect edge (3,6) three times + for i in range(3): + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g = graphs.Graph.from_graphtool(g_gt) + assert g.W[3,6] == 3.0 + + #test custom aggregator function + g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g2.W[3,6] == 1.0 + + eprop_double = g_gt.new_edge_property("double") + + #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3,6, all_edges=True) + eprop_double[e[0]] = 8.0 + eprop_double[e[1]] = 1.0 + + g_gt.edge_properties["weight"] = eprop_double + g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g3.W[3,6] == 3.0 + + def test_graphtool_import_export(self): + # Import to PyGSP and export again to graph tool directly + # create a random graphTool graph that does not contain multiple edges and no signal + g_gt = gt.Graph() + g_gt.add_vertex(100) + + # insert single random links + eprop_double = g_gt.new_edge_property("double") + for s, t in set(zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100))): + g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + + for e in g_gt.edges(): + eprop_double[e] = random.random() + g_gt.edge_properties["weight"] = eprop_double + + g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + + assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ + "the number of edge does not correspond" + + key = lambda e: str(e.source()) + ":" + str(e.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + assert v1 == v2 + + def test_networkx_singal_export(self): + logo = graphs.Logo() + s = np.random.random(logo.N) + s2 = np.random.random(logo.N) + logo.set_signal(s, "signal1") + logo.set_signal(s2, "signal2") + logo_nx = logo.to_networkx() + for i in range(50): + # Randomly check the signal of 50 nodes to see if they are the same + rd_node = np.random.randint(logo.N) + assert logo_nx.node[rd_node]["signal1"] == s[rd_node] + assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + + def test_graphtool_signal_export(self): + g = graphs.Logo() + s = np.random.random(g.N) + s2 = np.random.random(g.N) + g.set_signal(s, "signal1") + g.set_signal(s2, "signal2") + g_gt = g.to_graphtool() + #Check the signals on all nodes + for i, v in enumerate(g_gt.vertices()): + assert g_gt.vertex_properties["signal1"][v] == s[i] + assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): + g_gt = gt.Graph() + g_gt.add_vertex(10) + + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) + + vprop_double = g_gt.new_vertex_property("double") + + vprop_double[g_gt.vertex(0)] = 5 + vprop_double[g_gt.vertex(1)] = -3 + vprop_double[g_gt.vertex(2)] = 2.4 + + g_gt.vertex_properties["signal"] = vprop_double + g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + assert g.signals["signal"][0] == 5.0 + assert g.signals["signal"][1] == -3.0 + assert g.signals["signal"][2] == 2.4 + + def test_networkx_singal_import(self): + g_nx = nx.Graph() + g_nx.add_edge(3,4) + g_nx.add_edge(2,4) + g_nx.add_edge(3,5) + print(list(g_nx.node)[0]) + dic_signal = { + 2 : 4.0, + 3 : 5.0, + 4 : 3.3, + 5 : 2.3 + } + + nx.set_node_attributes(g_nx, dic_signal, "signal1") + g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + + nodes_mapping = list(g_nx.node) + for i in range(len(nodes_mapping)): + assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + + + def test_save_load(self): + g = graphs.Bunny() + g.save("bunny.gml") + g2 = graphs.Graph.load("bunny.gml") + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 15ff6b479f38a567a80a1c423d32fdb54f792be0 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 23 Sep 2018 15:17:46 +0800 Subject: [PATCH 091/365] remove test import file as it is now in the test_graph file --- pygsp/tests/test_import_export.py | 170 ------------------------------ 1 file changed, 170 deletions(-) delete mode 100644 pygsp/tests/test_import_export.py diff --git a/pygsp/tests/test_import_export.py b/pygsp/tests/test_import_export.py deleted file mode 100644 index fb5765eb..00000000 --- a/pygsp/tests/test_import_export.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Test suite for the Import and Export functionality inside the graphs module of the pygsp package. - -""" - -import unittest - -import numpy as np -import networkx as nx -import graph_tool as gt -import random - -from pygsp import graphs - -class TestCase(unittest.TestCase): - - def test_networkx_export_import(self): - #Export to networkx and reimport to PyGSP - - #Exporting the Bunny graph - g = graphs.Bunny() - g_nx = g.to_networkx() - g2 = graphs.Graph.from_networkx(g_nx) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - def test_networkx_import_export(self): - #Import from networkx then export to networkx again - g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph - g = graphs.Graph.from_networkx(g_nx).to_networkx() - - assert nx.is_isomorphic(g_nx, g) - np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), - nx.adjacency_matrix(g).todense()) - - def test_graphtool_export_import(self): - #Export to graph tool and reimport to PyGSP directly - #The exported graph is a simple one without an associated Signal - g = graphs.Bunny() - g_gt = g.to_graphtool() - g2 = graphs.Graph.from_graphtool(g_gt) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - - def test_graphtool_multiedge_import(self): - #Manualy create a graph with multiple edges - g_gt = gt.Graph() - g_gt.add_vertex(10) - #connect edge (3,6) three times - for i in range(3): - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3,6] == 3.0 - - #test custom aggregator function - g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3,6] == 1.0 - - eprop_double = g_gt.new_edge_property("double") - - #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 - e = g_gt.edge(3,6, all_edges=True) - eprop_double[e[0]] = 8.0 - eprop_double[e[1]] = 1.0 - - g_gt.edge_properties["weight"] = eprop_double - g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3,6] == 3.0 - - def test_graphtool_import_export(self): - # Import to PyGSP and export again to graph tool directly - # create a random graphTool graph that does not contain multiple edges and no signal - g_gt = gt.Graph() - g_gt.add_vertex(100) - - # insert single random links - eprop_double = g_gt.new_edge_property("double") - for s, t in set(zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100))): - g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) - - for e in g_gt.edges(): - eprop_double[e] = random.random() - g_gt.edge_properties["weight"] = eprop_double - - g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() - - assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ - "the number of edge does not correspond" - - key = lambda e: str(e.source()) + ":" + str(e.target()) - for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() - for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): - assert v1 == v2 - - def test_networkx_singal_export(self): - logo = graphs.Logo() - s = np.random.random(logo.N) - s2 = np.random.random(logo.N) - logo.set_signal(s, "signal1") - logo.set_signal(s2, "signal2") - logo_nx = logo.to_networkx() - for i in range(50): - # Randomly check the signal of 50 nodes to see if they are the same - rd_node = np.random.randint(logo.N) - assert logo_nx.node[rd_node]["signal1"] == s[rd_node] - assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] - - def test_graphtool_signal_export(self): - g = graphs.Logo() - s = np.random.random(g.N) - s2 = np.random.random(g.N) - g.set_signal(s, "signal1") - g.set_signal(s2, "signal2") - g_gt = g.to_graphtool() - #Check the signals on all nodes - for i, v in enumerate(g_gt.vertices()): - assert g_gt.vertex_properties["signal1"][v] == s[i] - assert g_gt.vertex_properties["signal2"][v] == s2[i] - def test_graphtool_signal_import(self): - g_gt = gt.Graph() - g_gt.add_vertex(10) - - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) - - vprop_double = g_gt.new_vertex_property("double") - - vprop_double[g_gt.vertex(0)] = 5 - vprop_double[g_gt.vertex(1)] = -3 - vprop_double[g_gt.vertex(2)] = 2.4 - - g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) - assert g.signals["signal"][0] == 5.0 - assert g.signals["signal"][1] == -3.0 - assert g.signals["signal"][2] == 2.4 - - def test_networkx_singal_import(self): - g_nx = nx.Graph() - g_nx.add_edge(3,4) - g_nx.add_edge(2,4) - g_nx.add_edge(3,5) - print(list(g_nx.node)[0]) - dic_signal = { - 2 : 4.0, - 3 : 5.0, - 4 : 3.3, - 5 : 2.3 - } - - nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) - - nodes_mapping = list(g_nx.node) - for i in range(len(nodes_mapping)): - assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] - - - def test_save_load(self): - g = graphs.Bunny() - g.save("bunny.gml") - g2 = graphs.Graph.load("bunny.gml") - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - -suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) \ No newline at end of file From 45ab89d99babdba1483540b55d8ac42315e95915 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 14:31:01 +0800 Subject: [PATCH 092/365] Some PR fixes --- pygsp/graphs/graph.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 85e46032..8af4d91c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -343,19 +343,20 @@ def save(self, path, fmt='auto', lib='networkx'): raise NotImplementedError('the format {} is not suported'.format(fmt)) - def set_signal(self, signal, signal_name): + def set_signal(self, signal, name): r""" Add or modify a signal to the graph Parameters ---------- signal : numpy.array - An array maping from node to his value. For example the value of the singal at node i is signal[i] + An array mapping from node to his value. For example the value of the signal at node i is signal[i] signal_name : String Name associated to the signal. """ - assert len(signal) == self.N, "A value must be attached to every vertex in the graph" - self.signals[signal_name] = np.array(signal) + if len(signal) == self.N: + raise ValueError("A value must be attached to every vertex in the graph") + self.signals[name] = np.asarray(signal) def check_weights(self): r"""Check the characteristics of the weights matrix. From 155016d3208360f78f82dcb00cf9d112934bf5c4 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:02:32 +0800 Subject: [PATCH 093/365] Fix test borken --- pygsp/graphs/graph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 8af4d91c..a38d4338 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -239,8 +239,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() else: - warnings.warn("""{} property not found in the graph, \ - weights of 1 for the edges are set""".format(edge_prop_name)) + warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" + .format(edge_prop_name)) edge_weight = np.ones(nb_vertex) # merging multi-edge @@ -354,7 +354,7 @@ def set_signal(self, signal, name): signal_name : String Name associated to the signal. """ - if len(signal) == self.N: + if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") self.signals[name] = np.asarray(signal) From 5d8f29455ab92f531611b6cbd3106a6f8b7b4142 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:07:31 +0800 Subject: [PATCH 094/365] Correct typo in signal names --- pygsp/graphs/graph.py | 16 ++++++++-------- pygsp/tests/test_graphs.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index a38d4338..ddbd4945 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -172,7 +172,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): return g_gt @classmethod - def from_networkx(cls, graph_nx, singals_names = []): + def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object The nodes are ordered according to methode `nodes()` from networkx @@ -180,7 +180,7 @@ def from_networkx(cls, graph_nx, singals_names = []): ---------- graph_nx : Graph A netowrkx instance of a graph - singals_names : list[String] + signals_names : list[String] List of signals names to import from the networkx graph Returns @@ -193,7 +193,7 @@ def from_networkx(cls, graph_nx, singals_names = []): A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) #Adding the signals - for s_name in singals_names: + for s_name in signals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) @@ -203,7 +203,7 @@ def from_networkx(cls, graph_nx, singals_names = []): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals_names = []): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names = []): r"""Build a graph from a graph tool object. Parameters @@ -220,7 +220,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. - singals_names : list[String] or 'all' + signals_names : list[String] or 'all' List of signals names to import from the graph_tool graph or if set to 'all' import all signal present in the graph @@ -252,9 +252,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals g = cls(W) #Adding signals - if singals_names == 'all': - singals_names = graph_gt.vertex_properties.keys() - for s_name in singals_names: + if signals_names == 'all': + signals_names = graph_gt.vertex_properties.keys() + for s_name in signals_names: if s_name in graph_gt.vertex_properties.keys(): s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) g.set_signal(s, s_name) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4e7c8b6a..862e69cb 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -644,7 +644,7 @@ def test_graphtool_import_export(self): for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): assert v1 == v2 - def test_networkx_singal_export(self): + def test_networkx_signal_export(self): logo = graphs.Logo() s = np.random.random(logo.N) s2 = np.random.random(logo.N) @@ -683,12 +683,12 @@ def test_graphtool_signal_import(self): vprop_double[g_gt.vertex(2)] = 2.4 g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) assert g.signals["signal"][0] == 5.0 assert g.signals["signal"][1] == -3.0 assert g.signals["signal"][2] == 2.4 - def test_networkx_singal_import(self): + def test_networkx_signal_import(self): g_nx = nx.Graph() g_nx.add_edge(3,4) g_nx.add_edge(2,4) @@ -702,7 +702,7 @@ def test_networkx_singal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + g = graphs.Graph.from_networkx(g_nx, signals_names=["signal1"]) nodes_mapping = list(g_nx.node) for i in range(len(nodes_mapping)): From d61984fb9496acff55dc4f3681668cb685061b6b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:31:41 +0800 Subject: [PATCH 095/365] Make use of intersphinx --- pygsp/graphs/graph.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ddbd4945..edc4eaad 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -130,7 +130,7 @@ def to_networkx(self): Returns ------- - g_nx : `Graph `_ + g_nx : :py:class:`networkx.Graph` """ import networkx as nx g = nx.from_scipy_sparse_matrix(self.W) @@ -155,9 +155,8 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): Returns ------- - g_gt : `Graph `_ + g_gt : :py:class:`graph_tool.Graph` """ - ##from graph_tool.all import * import graph_tool g_gt = graph_tool.Graph(directed=directed) nonzero = self.W.nonzero() @@ -175,11 +174,11 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object The nodes are ordered according to methode `nodes()` from networkx - + Parameters ---------- - graph_nx : Graph - A netowrkx instance of a graph + graph_nx : :py:class:`networkx.Graph` + A networkx instance of a graph signals_names : list[String] List of signals names to import from the networkx graph @@ -208,7 +207,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals Parameters ---------- - graph_gt : Graph + graph_gt : :py:class:`graph_tool.Graph` Graph tool object edge_prop_name : string Name of the `property `_ From c1fd64426b6cc29ab738269f993bee382a49c163 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:58:39 +0800 Subject: [PATCH 096/365] reorder import, and other PR fix --- pygsp/graphs/graph.py | 21 +++++++++++---------- pygsp/tests/test_graphs.py | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index edc4eaad..cdb11a75 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- +import warnings +from itertools import groupby from collections import Counter import numpy as np from scipy import sparse -from itertools import groupby -import warnings + from pygsp import utils from . import fourier, difference # prevent circular import in Python < 3.5 @@ -106,6 +107,7 @@ def __init__(self, W, lap_type='combinatorial', coords=None, plotting={}): 'edge_width': 2, 'edge_style': '-'} self.plotting.update(plotting) + self.signals = dict() # TODO: kept for backward compatibility. self.Ne = self.n_edges @@ -133,13 +135,14 @@ def to_networkx(self): g_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix(self.W) - for key in self.signals: - dic_signal = { i : self.signals[key][i] for i in range(0, len(self.signals[key]) ) } - nx.set_node_attributes(g, dic_signal, key) + g = nx.from_scipy_sparse_matrix(self.W, + create_using=nx.DiGraph if self.is_directed() else nx.Graph) + for name, signal in self.signals.items(): + signal_dict = {i: signal[i] for i in range(self.n_nodes)} + nx.set_node_attributes(g, signal_dict, name) return g - def to_graphtool(self, edge_prop_name='weight', directed=True): + def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double @@ -150,15 +153,13 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): edge_prop_name : string Name of the `property `_. By default it is set to `weight` - directed : bool - Indicate if the graph is `directed `_ Returns ------- g_gt : :py:class:`graph_tool.Graph` """ import graph_tool - g_gt = graph_tool.Graph(directed=directed) + g_gt = graph_tool.Graph(directed=self.is_directed()) nonzero = self.W.nonzero() g_gt.add_edge_list(np.transpose(nonzero)) edge_weight = g_gt.new_edge_property('double') diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 862e69cb..19124297 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -9,6 +9,7 @@ import sys import unittest +import random import numpy as np import scipy.linalg From b080e7606b915b96315c790add01729fffdb4456 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 17:48:56 +0800 Subject: [PATCH 097/365] Fix such that test passes --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index cdb11a75..62e1d839 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -136,7 +136,7 @@ def to_networkx(self): """ import networkx as nx g = nx.from_scipy_sparse_matrix(self.W, - create_using=nx.DiGraph if self.is_directed() else nx.Graph) + create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(g, signal_dict, name) From 906e5775ac7be9adb230282d7893fcc4e709d614 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:24:34 +0800 Subject: [PATCH 098/365] Use self.get_edge_list --- pygsp/graphs/graph.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 62e1d839..26a721c6 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -160,10 +160,9 @@ def to_graphtool(self, edge_prop_name='weight'): """ import graph_tool g_gt = graph_tool.Graph(directed=self.is_directed()) - nonzero = self.W.nonzero() - g_gt.add_edge_list(np.transpose(nonzero)) + g_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) edge_weight = g_gt.new_edge_property('double') - edge_weight.a = np.squeeze(np.array(self.W[nonzero])) + edge_weight.a = self.get_edge_list()[2] g_gt.edge_properties[edge_prop_name] = edge_weight for key in self.signals: vprop_double = g_gt.new_vertex_property("double") @@ -249,6 +248,10 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] + # When the graph is not directed the opposit edge as to be added too. + if not graph_gt.is_directed(): + for e in merged_edge_weight: + W[e[1], e[0]] = e[2] g = cls(W) #Adding signals From 675e92e44a0b22df46c6830a28d5f981c572371b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:48:28 +0800 Subject: [PATCH 099/365] Fixes for PR comment --- pygsp/graphs/graph.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 26a721c6..089c0891 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -164,16 +164,16 @@ def to_graphtool(self, edge_prop_name='weight'): edge_weight = g_gt.new_edge_property('double') edge_weight.a = self.get_edge_list()[2] g_gt.edge_properties[edge_prop_name] = edge_weight - for key in self.signals: + for name in self.signals: vprop_double = g_gt.new_vertex_property("double") - vprop_double.get_array()[:] = self.signals[key] - g_gt.vertex_properties[key] = vprop_double + vprop_double.get_array()[:] = self.signals[name] + g_gt.vertex_properties[name] = vprop_double return g_gt @classmethod def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object - The nodes are ordered according to methode `nodes()` from networkx + The nodes are ordered according to method `nodes()` from networkx Parameters ---------- @@ -274,9 +274,8 @@ def load(cls, path, fmt='auto', lib='networkx'): ---------- path : String Where the file is located on the disk. - fmt : String - Format in which the graph is encoded. Currently supported format are: - GML and gpickle. + fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} + Format in which the graph is encoded. lib : String Python library used in background to load the graph. Supported library are networkx and graph_tool @@ -289,6 +288,9 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] + if format not in ['graphml', 'gml', 'gexf', 'dot']: + raise ValueError('Unsupported format {}.'.format(fmt)) + err = NotImplementedError('{} can not be load with {}. \ Try another background library'.format(fmt, lib)) From f7072f79bbc4f666b4b85149893d5a00182b1645 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:50:59 +0800 Subject: [PATCH 100/365] Bug fix --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 089c0891..7cc2a252 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -288,7 +288,7 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - if format not in ['graphml', 'gml', 'gexf', 'dot']: + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) err = NotImplementedError('{} can not be load with {}. \ From e8bef2736029aa8cc0d7d4637bd2b0644bdbb512 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 20:48:43 +0800 Subject: [PATCH 101/365] Remove some lint error --- pygsp/graphs/graph.py | 45 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7cc2a252..e3829903 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -128,15 +128,17 @@ def __repr__(self, limit=None): return '{}({})'.format(self.__class__.__name__, s[:-2]) def to_networkx(self): - r"""Export the graph to an `Networkx `_ object + r"""Export the graph to an `Networkx `_ object Returns ------- g_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix(self.W, - create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) + g = nx.from_scipy_sparse_matrix( + self.W, create_using=nx.DiGraph() + if self.is_directed() else nx.Graph()) + for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(g, signal_dict, name) @@ -144,14 +146,14 @@ def to_networkx(self): def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object - The weights of the graph are stored in a `property maps `_ - of type double + The weights of the graph are stored in a `property maps `_ of type double WARNING: The edges and vertex property will be converted into double type Parameters ---------- - edge_prop_name : string - Name of the `property `_. + edge_prop_name : string + Name of the property in :py:attr:`graph_tool.Graph.edge_properties`. By default it is set to `weight` Returns @@ -171,7 +173,7 @@ def to_graphtool(self, edge_prop_name='weight'): return g_gt @classmethod - def from_networkx(cls, graph_nx, signals_names = []): + def from_networkx(cls, graph_nx, signals_names=[]): r"""Build a graph from a Networkx object The nodes are ordered according to method `nodes()` from networkx @@ -181,36 +183,36 @@ def from_networkx(cls, graph_nx, signals_names = []): A networkx instance of a graph signals_names : list[String] List of signals names to import from the networkx graph - + Returns ------- g : :class:`~pygsp.graphs.Graph` """ import networkx as nx - #keep a consistent order of nodes for the agency matrix and the signal array + # keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) - #Adding the signals + # Adding the signals for s_name in signals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - #The signal is set to zero for node not present in the networkx signal + # The signal is set to zero for node not present in the networkx signal s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) G.set_signal(s_value, s_name) return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names = []): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): r"""Build a graph from a graph tool object. - + Parameters ---------- graph_gt : :py:class:`graph_tool.Graph` Graph tool object edge_prop_name : string - Name of the `property `_ + Name of the `property `_ to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please @@ -227,7 +229,6 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals ------- g : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` - """ nb_vertex = len(graph_gt.get_vertices()) W = np.zeros(shape=(nb_vertex, nb_vertex)) @@ -254,7 +255,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals W[e[1], e[0]] = e[2] g = cls(W) - #Adding signals + # Adding signals if signals_names == 'all': signals_names = graph_gt.vertex_properties.keys() for s_name in signals_names: @@ -267,7 +268,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals @classmethod def load(cls, path, fmt='auto', lib='networkx'): - r"""Load a graph from a file using networkx for import. + r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt Parameters @@ -279,11 +280,10 @@ def load(cls, path, fmt='auto', lib='networkx'): lib : String Python library used in background to load the graph. Supported library are networkx and graph_tool - + Returns ------- g : :class:`~pygsp.graphs.Graph` - """ if fmt == 'auto': fmt = path.split('.')[-1] @@ -312,7 +312,7 @@ def load(cls, path, fmt='auto', lib='networkx'): def save(self, path, fmt='auto', lib='networkx'): r"""Save the graph into a file - + Parameters ---------- path : String @@ -327,7 +327,7 @@ def save(self, path, fmt='auto', lib='networkx'): Supported library are networkx and graph_tool """ if fmt == 'auto': - fmt = path.split('.')[-1] + fmt = path.split('.')[-1] err = NotImplementedError('{} can not be save with {}. \ Try another background library'.format(fmt, lib)) @@ -341,7 +341,6 @@ def save(self, path, fmt='auto', lib='networkx'): raise err if lib == 'graph_tool': - import graph_tool g = self.to_graphtool() g.save(path, fmt=fmt) return From e31963cdc74defe98a2517e62b700f3598d238c9 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 21:02:50 +0800 Subject: [PATCH 102/365] resolve some make lint inssues --- pygsp/tests/test_graphs.py | 53 ++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 19124297..aaef1840 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -561,22 +561,24 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) + class TestCaseImportExport(unittest.TestCase): def test_networkx_export_import(self): - #Export to networkx and reimport to PyGSP + # Export to networkx and reimport to PyGSP - #Exporting the Bunny graph + # Exporting the Bunny graph g = graphs.Bunny() g_nx = g.to_networkx() g2 = graphs.Graph.from_networkx(g_nx) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) def test_networkx_import_export(self): - #Import from networkx then export to networkx again - g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + # Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() assert nx.is_isomorphic(g_nx, g) @@ -584,38 +586,37 @@ def test_networkx_import_export(self): nx.adjacency_matrix(g).todense()) def test_graphtool_export_import(self): - #Export to graph tool and reimport to PyGSP directly - #The exported graph is a simple one without an associated Signal + # Export to graph tool and reimport to PyGSP directly + # The exported graph is a simple one without an associated Signal g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - def test_graphtool_multiedge_import(self): - #Manualy create a graph with multiple edges + # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) - #connect edge (3,6) three times + # connect edge (3,6) three times for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3,6] == 3.0 + assert g.W[3, 6] == 3.0 - #test custom aggregator function + # test custom aggregator function g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3,6] == 1.0 + assert g2.W[3, 6] == 1.0 eprop_double = g_gt.new_edge_property("double") - #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 - e = g_gt.edge(3,6, all_edges=True) + # Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3, 6, all_edges=True) eprop_double[e[0]] = 8.0 eprop_double[e[1]] = 1.0 g_gt.edge_properties["weight"] = eprop_double g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3,6] == 3.0 + assert g3.W[3, 6] == 3.0 def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly @@ -638,7 +639,8 @@ def test_graphtool_import_export(self): assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ "the number of edge does not correspond" - key = lambda e: str(e.source()) + ":" + str(e.target()) + def key(edge): return str(edge.source()) + ":" + str(edge.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): assert e1.source() == e2.source() assert e1.target() == e2.target() @@ -665,10 +667,11 @@ def test_graphtool_signal_export(self): g.set_signal(s, "signal1") g.set_signal(s2, "signal2") g_gt = g.to_graphtool() - #Check the signals on all nodes + # Check the signals on all nodes for i, v in enumerate(g_gt.vertices()): assert g_gt.vertex_properties["signal1"][v] == s[i] assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): g_gt = gt.Graph() g_gt.add_vertex(10) @@ -691,15 +694,15 @@ def test_graphtool_signal_import(self): def test_networkx_signal_import(self): g_nx = nx.Graph() - g_nx.add_edge(3,4) - g_nx.add_edge(2,4) - g_nx.add_edge(3,5) + g_nx.add_edge(3, 4) + g_nx.add_edge(2, 4) + g_nx.add_edge(3, 5) print(list(g_nx.node)[0]) dic_signal = { - 2 : 4.0, - 3 : 5.0, - 4 : 3.3, - 5 : 2.3 + 2: 4.0, + 3: 5.0, + 4: 3.3, + 5: 2.3 } nx.set_node_attributes(g_nx, dic_signal, "signal1") @@ -709,11 +712,11 @@ def test_networkx_signal_import(self): for i in range(len(nodes_mapping)): assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] - def test_save_load(self): g = graphs.Bunny() g.save("bunny.gml") g2 = graphs.Graph.load("bunny.gml") np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 3b09921301231492403eb01770e2cf80e8747996 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 16:52:06 +0800 Subject: [PATCH 103/365] Var renaming + adding weight param to from_networkx --- pygsp/graphs/graph.py | 65 ++++++++++++++++++++------------------ pygsp/tests/test_graphs.py | 2 +- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e3829903..32a2c007 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -132,20 +132,21 @@ def to_networkx(self): Returns ------- - g_nx : :py:class:`networkx.Graph` + graph_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix( + graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} - nx.set_node_attributes(g, signal_dict, name) - return g + nx.set_node_attributes(graph_nx, signal_dict, name) + return graph_nx def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object + The weights of the graph are stored in a `property maps `_ of type double WARNING: The edges and vertex property will be converted into double type @@ -158,50 +159,54 @@ def to_graphtool(self, edge_prop_name='weight'): Returns ------- - g_gt : :py:class:`graph_tool.Graph` + graph_gt : :py:class:`graph_tool.Graph` """ import graph_tool - g_gt = graph_tool.Graph(directed=self.is_directed()) - g_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) - edge_weight = g_gt.new_edge_property('double') + graph_gt = graph_tool.Graph(directed=self.is_directed()) + graph_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) + edge_weight = graph_gt.new_edge_property('double') edge_weight.a = self.get_edge_list()[2] - g_gt.edge_properties[edge_prop_name] = edge_weight + graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - vprop_double = g_gt.new_vertex_property("double") + vprop_double = graph_gt.new_vertex_property("double") vprop_double.get_array()[:] = self.signals[name] - g_gt.vertex_properties[name] = vprop_double - return g_gt + graph_gt.vertex_properties[name] = vprop_double + return graph_gt @classmethod - def from_networkx(cls, graph_nx, signals_names=[]): + def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): r"""Build a graph from a Networkx object + The nodes are ordered according to method `nodes()` from networkx Parameters ---------- graph_nx : :py:class:`networkx.Graph` A networkx instance of a graph - signals_names : list[String] + signals_name : list[String] List of signals names to import from the networkx graph + weight : (string or None optional (default=’weight’)) + The edge attribute that holds the numerical value used for the edge weight. + If None then all edge weights are 1. Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() - A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) - G = cls(A) + adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) + graph = cls(adjacency) # Adding the signals - for s_name in signals_names: + for s_name in signals_name: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) # The signal is set to zero for node not present in the networkx signal s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) - G.set_signal(s_value, s_name) - return G + graph.set_signal(s_value, s_name) + return graph @classmethod def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): @@ -227,11 +232,11 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ nb_vertex = len(graph_gt.get_vertices()) - W = np.zeros(shape=(nb_vertex, nb_vertex)) + weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -248,23 +253,23 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: - W[e[0], e[1]] = e[2] + weights[e[0], e[1]] = e[2] # When the graph is not directed the opposit edge as to be added too. if not graph_gt.is_directed(): for e in merged_edge_weight: - W[e[1], e[0]] = e[2] - g = cls(W) + weights[e[1], e[0]] = e[2] + graph = cls(weights) # Adding signals if signals_names == 'all': signals_names = graph_gt.vertex_properties.keys() for s_name in signals_names: if s_name in graph_gt.vertex_properties.keys(): - s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) - g.set_signal(s, s_name) + signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) + graph.set_signal(signal, s_name) else: warnings.warn("{} was not found in the graph_tool graph".format(s_name)) - return g + return graph @classmethod def load(cls, path, fmt='auto', lib='networkx'): @@ -283,7 +288,7 @@ def load(cls, path, fmt='auto', lib='networkx'): Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` """ if fmt == 'auto': fmt = path.split('.')[-1] @@ -355,7 +360,7 @@ def set_signal(self, signal, name): ---------- signal : numpy.array An array mapping from node to his value. For example the value of the signal at node i is signal[i] - signal_name : String + name : String Name associated to the signal. """ if len(signal) != self.N: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index aaef1840..bac12521 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -706,7 +706,7 @@ def test_networkx_signal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, signals_names=["signal1"]) + g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) nodes_mapping = list(g_nx.node) for i in range(len(nodes_mapping)): From 257b901fe89c09e76a3b4d415b8b605bdc148758 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 17:20:53 +0800 Subject: [PATCH 104/365] Change assert to self.assertEqual --- pygsp/tests/test_graphs.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index bac12521..06bde128 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -581,7 +581,7 @@ def test_networkx_import_export(self): g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() - assert nx.is_isomorphic(g_nx, g) + self.assertTrue(nx.is_isomorphic(g_nx, g)) np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) @@ -601,11 +601,11 @@ def test_graphtool_multiedge_import(self): for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3, 6] == 3.0 + self.assertEqual(g.W[3, 6], 3.0) # test custom aggregator function g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3, 6] == 1.0 + self.assertEqual(g2.W[3, 6], 1.0) eprop_double = g_gt.new_edge_property("double") @@ -616,7 +616,7 @@ def test_graphtool_multiedge_import(self): g_gt.edge_properties["weight"] = eprop_double g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3, 6] == 3.0 + self.assertEqual(g3.W[3, 6], 3.0) def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly @@ -642,10 +642,10 @@ def test_graphtool_import_export(self): def key(edge): return str(edge.source()) + ":" + str(edge.target()) for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() + self.assertEqual(e1.source(), e2.source()) + self.assertEqual(e1.target(), e2.target()) for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): - assert v1 == v2 + self.assertEqual(v1, v2) def test_networkx_signal_export(self): logo = graphs.Logo() @@ -657,8 +657,8 @@ def test_networkx_signal_export(self): for i in range(50): # Randomly check the signal of 50 nodes to see if they are the same rd_node = np.random.randint(logo.N) - assert logo_nx.node[rd_node]["signal1"] == s[rd_node] - assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + self.assertEqual(logo_nx.node[rd_node]["signal1"], s[rd_node]) + self.assertEqual(logo_nx.node[rd_node]["signal2"], s2[rd_node]) def test_graphtool_signal_export(self): g = graphs.Logo() @@ -669,8 +669,8 @@ def test_graphtool_signal_export(self): g_gt = g.to_graphtool() # Check the signals on all nodes for i, v in enumerate(g_gt.vertices()): - assert g_gt.vertex_properties["signal1"][v] == s[i] - assert g_gt.vertex_properties["signal2"][v] == s2[i] + self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) + self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) def test_graphtool_signal_import(self): g_gt = gt.Graph() @@ -688,9 +688,9 @@ def test_graphtool_signal_import(self): g_gt.vertex_properties["signal"] = vprop_double g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) - assert g.signals["signal"][0] == 5.0 - assert g.signals["signal"][1] == -3.0 - assert g.signals["signal"][2] == 2.4 + self.assertEqual(g.signals["signal"][0], 5.0) + self.assertEqual(g.signals["signal"][1], -3.0) + self.assertEqual(g.signals["signal"][2], 2.4) def test_networkx_signal_import(self): g_nx = nx.Graph() @@ -708,9 +708,9 @@ def test_networkx_signal_import(self): nx.set_node_attributes(g_nx, dic_signal, "signal1") g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) - nodes_mapping = list(g_nx.node) - for i in range(len(nodes_mapping)): - assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + for i, node in enumerate(g_nx.node): + self.assertEqual(g.signals["signal1"][i], + nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): g = graphs.Bunny() From 2bb571dfef2da96042b4e6e54cc99d51e3773601 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 17:56:10 +0800 Subject: [PATCH 105/365] Change one assert --- pygsp/tests/test_graphs.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 06bde128..68d24d7a 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -621,30 +621,30 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - g_gt = gt.Graph() - g_gt.add_vertex(100) + graph_gt = gt.Graph() + graph_gt.add_vertex(100) # insert single random links - eprop_double = g_gt.new_edge_property("double") + eprop_double = graph_gt.new_edge_property("double") for s, t in set(zip(np.random.randint(0, 100, 100), np.random.randint(0, 100, 100))): - g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + graph_gt.add_edge(graph_gt.vertex(s), graph_gt.vertex(t)) - for e in g_gt.edges(): + for e in graph_gt.edges(): eprop_double[e] = random.random() - g_gt.edge_properties["weight"] = eprop_double + graph_gt.edge_properties["weight"] = eprop_double - g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + graph2_gt = graphs.Graph.from_graphtool(graph_gt).to_graphtool() - assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ - "the number of edge does not correspond" + self.assertEqual(graph_gt.num_edges(), graph2_gt.num_edges(), + "the number of edges does not correspond") def key(edge): return str(edge.source()) + ":" + str(edge.target()) - for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + for e1, e2 in zip(sorted(graph_gt.edges(), key=key), sorted(graph2_gt.edges(), key=key)): self.assertEqual(e1.source(), e2.source()) self.assertEqual(e1.target(), e2.target()) - for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + for v1, v2 in zip(graph_gt.vertices(), graph2_gt.vertices()): self.assertEqual(v1, v2) def test_networkx_signal_export(self): From 8ae6fe789ba7ce8b0c30f471cd33a783f3ff8c3e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Oct 2018 00:28:38 +0800 Subject: [PATCH 106/365] Setting the seed for random tests --- pygsp/tests/test_graphs.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 68d24d7a..d0d498b9 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -648,20 +648,20 @@ def key(edge): return str(edge.source()) + ":" + str(edge.target()) self.assertEqual(v1, v2) def test_networkx_signal_export(self): - logo = graphs.Logo() - s = np.random.random(logo.N) - s2 = np.random.random(logo.N) - logo.set_signal(s, "signal1") - logo.set_signal(s2, "signal2") - logo_nx = logo.to_networkx() - for i in range(50): - # Randomly check the signal of 50 nodes to see if they are the same - rd_node = np.random.randint(logo.N) - self.assertEqual(logo_nx.node[rd_node]["signal1"], s[rd_node]) - self.assertEqual(logo_nx.node[rd_node]["signal2"], s2[rd_node]) + graph = graphs.BarabasiAlbert(N=100, seed=42) + np.random.seed(42) + signal1 = np.random.random(graph.N) + signal2 = np.random.random(graph.N) + graph.set_signal(signal1, "signal1") + graph.set_signal(signal2, "signal2") + graph_nx = graph.to_networkx() + for i in range(graph.n_nodes): + self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) + self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) def test_graphtool_signal_export(self): g = graphs.Logo() + np.random.seed(42) s = np.random.random(g.N) s2 = np.random.random(g.N) g.set_signal(s, "signal1") From d68a4c3f1e98a49d6024c3353d3ecb8adfd90a6d Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 11:55:56 +0800 Subject: [PATCH 107/365] Save and Load reimplemented --- pygsp/graphs/graph.py | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 32a2c007..b97ec7fc 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -296,24 +296,21 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - err = NotImplementedError('{} can not be load with {}. \ - Try another background library'.format(fmt, lib)) - - if lib == 'networkx': - import networkx - if fmt == 'gml': - g = networkx.read_gml(path) - return cls.from_networkx(g) - if fmt in ['gpickle', 'p', 'pkl', 'pickle']: - g = networkx.read_gpickle(path) - return cls.from_networkx(g) - raise err - if lib == 'graph_tool': - import graph_tool - g = graph_tool.load_graph(path, fmt=fmt) - return cls.from_graphtool(g) - - raise NotImplementedError('the format {} is not suported'.format(fmt)) + if fmt in ['graphml', 'gml', 'gexf']: + try: + import networkx as nx + load = getattr(nx, 'read_' + fmt) + return cls.from_networkx(load(path)) + except ModuleNotFoundError: + pass + if fmt in ['graphml', 'gml', 'dot']: + try: + import graph_tool as gt + graph_gt = gt.load_graph(path, fmt=fmt) + return cls.from_graphtool(graph_gt) + except ModuleNotFoundError: + pass + raise ModuleNotFoundError("Please install either networkx or graph_tool") def save(self, path, fmt='auto', lib='networkx'): r"""Save the graph into a file @@ -334,23 +331,26 @@ def save(self, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - err = NotImplementedError('{} can not be save with {}. \ - Try another background library'.format(fmt, lib)) - - if lib == 'networkx': - import networkx - if fmt == 'gml': - g = self.to_networkx() - networkx.write_gml(g, path) - return - raise err - - if lib == 'graph_tool': - g = self.to_graphtool() - g.save(path, fmt=fmt) - return + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: + raise ValueError('Unsupported format {}.'.format(fmt)) - raise NotImplementedError('the format {} is not suported'.format(fmt)) + if fmt in ['graphml', 'gml', 'gexf']: + try: + import networkx as nx + graph_nx = self.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph_nx, path) + return None + except ModuleNotFoundError: + pass + if fmt in ['graphml', 'gml', 'dot']: + try: + graph_gt = self.to_graphtool() + graph_gt.save(path, fmt=fmt) + return None + except ModuleNotFoundError: + pass + raise ModuleNotFoundError("Please install either networkx or graph_tool") def set_signal(self, signal, name): r""" From 59b3599dd3b174f5c137599e7dfc64e067fdca34 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 11:58:42 +0800 Subject: [PATCH 108/365] fix overloading suite error --- pygsp/tests/test_graphs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index d0d498b9..0b6a34a1 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -562,7 +562,7 @@ def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) -suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) +suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) class TestCaseImportExport(unittest.TestCase): @@ -719,4 +719,5 @@ def test_save_load(self): np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) -suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) +suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) +suite = unittest.TestSuite([suite_graphs, suite_import_export]) From 521241d3171e2668e69e4f6550bff83d085912e8 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 12:56:23 +0800 Subject: [PATCH 109/365] Add test for save/load of multiple format + some bug fix load and save to "dot", "graphml" are not working yet --- pygsp/graphs/graph.py | 9 +++++++-- pygsp/tests/test_graphs.py | 13 ++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b97ec7fc..f6b5c0fb 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -235,7 +235,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ - nb_vertex = len(graph_gt.get_vertices()) + nb_vertex = graph_gt.num_vertices() + nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -243,10 +244,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals if edge_prop_name in props_names: prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() + if edge_weight is None: + warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") + edge_weight = np.ones(nb_edges) + else: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" .format(edge_prop_name)) - edge_weight = np.ones(nb_vertex) + edge_weight = np.ones(nb_edges) # merging multi-edge merged_edge_weight = [] diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 0b6a34a1..f3877c85 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -10,6 +10,7 @@ import sys import unittest import random +import os import numpy as np import scipy.linalg @@ -714,9 +715,15 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - g.save("bunny.gml") - g2 = graphs.Graph.load("bunny.gml") - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + tested_fmt = ["gml", "gexf"] #"dot", "graphml" + for fmt in tested_fmt: + g.save("bunny." + fmt) + + for fmt in tested_fmt: + graph_loaded = graphs.Graph.load("bunny." + fmt) + np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + os.remove("bunny." + fmt) + suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From acbfc92fb8623df4df38552b74cd319b56dc035d Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 19:34:41 +0800 Subject: [PATCH 110/365] Add doc for to_networkx + argument to store weight under another name --- pygsp/graphs/graph.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index f6b5c0fb..48f6f0c3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -127,9 +127,19 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self): + def to_networkx(self, edge_prop_name='weight'): r"""Export the graph to an `Networkx `_ object + The weight are stored as an edge attribute under named `edge_prop_name` + The signals are stored as node attributes under the same name as define in PyGSP + :func:`~pygsp.graphs.Graph.set_signal`. + + Parameters + ---------- + edge_prop_name : string + Name of edge attribute to store matrix numeric value. + As the attibute edge_attribute in :py:func:`networkx.convert_matrix.from_scipy_sparse_matrix`. + Returns ------- graph_nx : :py:class:`networkx.Graph` @@ -137,7 +147,8 @@ def to_networkx(self): import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() - if self.is_directed() else nx.Graph()) + if self.is_directed() else nx.Graph(), + edge_attribute=edge_prop_name) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} From 527ac1cbb6dc9e2a3b1e24800bf4eafa4c64c639 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 19:53:05 +0800 Subject: [PATCH 111/365] Fix https://github.com/epfl-lts2/pygsp/pull/32#discussion_r223394071 --- pygsp/graphs/graph.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 48f6f0c3..5b4487f6 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -174,9 +174,10 @@ def to_graphtool(self, edge_prop_name='weight'): """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) - graph_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) + v_in, v_out, weights = self.get_edge_list() + graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) edge_weight = graph_gt.new_edge_property('double') - edge_weight.a = self.get_edge_list()[2] + edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: vprop_double = graph_gt.new_vertex_property("double") From 1a982ff8d83ce7786c89aa2d0142fe4d0df879d6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 20:26:36 +0800 Subject: [PATCH 112/365] Auto detect type of attributes when exporting to graphtool --- pygsp/graphs/graph.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5b4487f6..1f302bea 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -172,15 +172,39 @@ def to_graphtool(self, edge_prop_name='weight'): ------- graph_gt : :py:class:`graph_tool.Graph` """ + + # Encode the numpy types with its correspondence in graph_tool + numpy2gt_type = { + np.bool_: 'bool', + np.int_: 'int', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.float_: 'long double', + np.float16: 'double', + np.float32: 'double', + np.float64: 'long double' + } + import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - edge_weight = graph_gt.new_edge_property('double') + try: + weight_type_str = numpy2gt_type[weights.dtype.type] + except KeyError: + raise ValueError("Type {} for the weights is not supported" + .format(str(weights.dtype))) + edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - vprop_double = graph_gt.new_vertex_property("double") + try: + edge_type_str = numpy2gt_type[weights.dtype.type] + except KeyError: + raise ValueError("Type {} from signal {} is not supported" + .format(str(self.signals[name].dtype), name)) + vprop_double = graph_gt.new_vertex_property(edge_type_str) vprop_double.get_array()[:] = self.signals[name] graph_gt.vertex_properties[name] = vprop_double return graph_gt From 52799063ea34538d7bf3bb8ae4e4dc8a451f02a7 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 21:05:59 +0800 Subject: [PATCH 113/365] Move the dict for type conversion into utils. --- pygsp/graphs/graph.py | 25 +++++-------------------- pygsp/tests/test_graphs.py | 1 - pygsp/utils.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1f302bea..fb26aeca 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -173,35 +173,20 @@ def to_graphtool(self, edge_prop_name='weight'): graph_gt : :py:class:`graph_tool.Graph` """ - # Encode the numpy types with its correspondence in graph_tool - numpy2gt_type = { - np.bool_: 'bool', - np.int_: 'int', - np.int16: 'int16_t', - np.int32: 'int32_t', - np.int64: 'int64_t', - np.float_: 'long double', - np.float16: 'double', - np.float32: 'double', - np.float64: 'long double' - } - import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - try: - weight_type_str = numpy2gt_type[weights.dtype.type] - except KeyError: + weight_type_str = utils.numpy2graph_tool_type(weights.dtype) + if weight_type_str is None: raise ValueError("Type {} for the weights is not supported" .format(str(weights.dtype))) edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - try: - edge_type_str = numpy2gt_type[weights.dtype.type] - except KeyError: + edge_type_str = utils.numpy2graph_tool_type(weights.dtype) + if edge_type_str is None: raise ValueError("Type {} from signal {} is not supported" .format(str(self.signals[name].dtype), name)) vprop_double = graph_gt.new_vertex_property(edge_type_str) @@ -211,7 +196,7 @@ def to_graphtool(self, edge_prop_name='weight'): @classmethod def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): - r"""Build a graph from a Networkx object + r"""Build a graph from a Networkx object. The nodes are ordered according to method `nodes()` from networkx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index f3877c85..48cf047c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -698,7 +698,6 @@ def test_networkx_signal_import(self): g_nx.add_edge(3, 4) g_nx.add_edge(2, 4) g_nx.add_edge(3, 5) - print(list(g_nx.node)[0]) dic_signal = { 2: 4.0, 3: 5.0, diff --git a/pygsp/utils.py b/pygsp/utils.py index 94474eb1..03e4cbdb 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -364,3 +364,40 @@ def import_functions(names, src, dst): for name in names: module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) + +def numpy2graph_tool_type(dtype): + r"""Convert from numpy dtype to graph tool types. + + The supported numpy types are: {bool_, int_, int16, int32, int64, + float_, float16, float32, float64} + See graph_tool `doc `_ for more details. + + Parameters + ---------- + dtype : :py:class:`numpy.dtype` + + Returns + ------- + graph_tool_type : string + A string representing the type ready to be use by graph_tool + + """ + # Encode the numpy types with its correspondence in graph_tool + numpy2gt_type = { + np.bool_: 'bool', + np.int_: 'int', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.float_: 'long double', + np.float16: 'double', + np.float32: 'double', + np.float64: 'long double' + } + + try: + graph_tool_type = numpy2gt_type[dtype.type] + except: + graph_tool_type = None + + return graph_tool_type From 53f56ec64e2893da62da3bbaf3baff55e0184a25 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 11:23:43 +0800 Subject: [PATCH 114/365] ask for forgiveness rather than permission --- pygsp/graphs/graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index fb26aeca..9ebfaf08 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -262,14 +262,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals props_names = graph_gt.edge_properties.keys() - if edge_prop_name in props_names: + try: prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) - else: + except KeyError: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" .format(edge_prop_name)) edge_weight = np.ones(nb_edges) From 467c2a6d84610709f667ee7191935a4805f55473 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 11:55:06 +0800 Subject: [PATCH 115/365] remove edge property name from to_graphtool and to_networkx --- pygsp/graphs/graph.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9ebfaf08..4810780e 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -127,19 +127,13 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self, edge_prop_name='weight'): + def to_networkx(self): r"""Export the graph to an `Networkx `_ object - The weight are stored as an edge attribute under named `edge_prop_name` + The weight are stored as an edge attribute under named `weight` The signals are stored as node attributes under the same name as define in PyGSP :func:`~pygsp.graphs.Graph.set_signal`. - Parameters - ---------- - edge_prop_name : string - Name of edge attribute to store matrix numeric value. - As the attibute edge_attribute in :py:func:`networkx.convert_matrix.from_scipy_sparse_matrix`. - Returns ------- graph_nx : :py:class:`networkx.Graph` @@ -148,31 +142,23 @@ def to_networkx(self, edge_prop_name='weight'): graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() if self.is_directed() else nx.Graph(), - edge_attribute=edge_prop_name) + edge_attribute='weight') for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx - def to_graphtool(self, edge_prop_name='weight'): + def to_graphtool(self): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double - WARNING: The edges and vertex property will be converted into double type - - Parameters - ---------- - edge_prop_name : string - Name of the property in :py:attr:`graph_tool.Graph.edge_properties`. - By default it is set to `weight` + quickstart.html#internal-property-maps>`_ under the name `weight` Returns ------- graph_gt : :py:class:`graph_tool.Graph` """ - import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() @@ -183,7 +169,7 @@ def to_graphtool(self, edge_prop_name='weight'): .format(str(weights.dtype))) edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights - graph_gt.edge_properties[edge_prop_name] = edge_weight + graph_gt.edge_properties['weight'] = edge_weight for name in self.signals: edge_type_str = utils.numpy2graph_tool_type(weights.dtype) if edge_type_str is None: From 073cdb9d46575b567ec05fda42ddfce02a9b6f5e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 12:48:04 +0800 Subject: [PATCH 116/365] Import all the signals as asked in https://github.com/epfl-lts2/pygsp/pull/32#discussion_r223418494 --- pygsp/graphs/graph.py | 38 ++++++++++++++++++++++++-------------- pygsp/tests/test_graphs.py | 5 ++--- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 4810780e..0ebe7a4c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -181,17 +181,21 @@ def to_graphtool(self): return graph_gt @classmethod - def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): + def from_networkx(cls, graph_nx, weight='weight'): r"""Build a graph from a Networkx object. The nodes are ordered according to method `nodes()` from networkx + When a node attribute is not present for node a value of zero is assign + to the corresponding signal on that node. + + When the networkx graph is an instance of :py:class:`networkx.MultiGraph`, + multiple edge are aggregated by summation. + Parameters ---------- graph_nx : :py:class:`networkx.Graph` A networkx instance of a graph - signals_name : list[String] - List of signals names to import from the networkx graph weight : (string or None optional (default=’weight’)) The edge attribute that holds the numerical value used for the edge weight. If None then all edge weights are 1. @@ -206,17 +210,25 @@ def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) graph = cls(adjacency) # Adding the signals - for s_name in signals_name: - s_dict = nx.get_node_attributes(graph_nx, s_name) - if len(s_dict.keys()) == 0: - raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - # The signal is set to zero for node not present in the networkx signal - s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) - graph.set_signal(s_value, s_name) + signals = dict() + for i, node in enumerate(nodelist): + signals_name = graph_nx.nodes[node].keys() + + # Add signal previously not present in the dict of signal + # Set to zero the value of the signal when not present for a node + # in Networkx + for signal in set(signals_name) - set(signals.keys()): + signals[signal] = np.zeros(len(nodelist)) + + # Set the value of the signal + for signal in signals_name: + signals[signal][i] = graph_nx.nodes[node][signal] + + graph.signals = signals return graph @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): r"""Build a graph from a graph tool object. Parameters @@ -273,9 +285,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals graph = cls(weights) # Adding signals - if signals_names == 'all': - signals_names = graph_gt.vertex_properties.keys() - for s_name in signals_names: + for s_name in graph_gt.vertex_properties.keys(): if s_name in graph_gt.vertex_properties.keys(): signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) graph.set_signal(signal, s_name) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 48cf047c..f080d04f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -688,7 +688,7 @@ def test_graphtool_signal_import(self): vprop_double[g_gt.vertex(2)] = 2.4 g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) + g = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g.signals["signal"][0], 5.0) self.assertEqual(g.signals["signal"][1], -3.0) self.assertEqual(g.signals["signal"][2], 2.4) @@ -706,7 +706,7 @@ def test_networkx_signal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) + g = graphs.Graph.from_networkx(g_nx) for i, node in enumerate(g_nx.node): self.assertEqual(g.signals["signal1"][i], @@ -724,6 +724,5 @@ def test_save_load(self): os.remove("bunny." + fmt) - suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) suite = unittest.TestSuite([suite_graphs, suite_import_export]) From 3f605fb6f3ed619d646ad9c62f61c7ee8f39d488 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 17 Oct 2018 21:37:57 +0800 Subject: [PATCH 117/365] Simplify from_graphtool() --- pygsp/graphs/graph.py | 18 +++++++----------- pygsp/tests/test_graphs.py | 8 ++------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0ebe7a4c..5d153017 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -228,26 +228,22 @@ def from_networkx(cls, graph_nx, weight='weight'): return graph @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): + def from_graphtool(cls, graph_gt, weight='weight'): r"""Build a graph from a graph tool object. + When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. + Parameters ---------- graph_gt : :py:class:`graph_tool.Graph` Graph tool object - edge_prop_name : string + weight : string Name of the `property `_ to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please use `set_value `_ from the graph_tool object. - aggr_fun : function - When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the - edges. By default the sum is taken. - signals_names : list[String] or 'all' - List of signals names to import from the graph_tool graph or if set to 'all' import all signal present - in the graph Returns ------- @@ -261,7 +257,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): props_names = graph_gt.edge_properties.keys() try: - prop = graph_gt.edge_properties[edge_prop_name] + prop = graph_gt.edge_properties[weight] edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") @@ -269,13 +265,13 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): except KeyError: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" - .format(edge_prop_name)) + .format(weight)) edge_weight = np.ones(nb_edges) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) + merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: weights[e[0], e[1]] = e[2] # When the graph is not directed the opposit edge as to be added too. diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index f080d04f..8ee7017f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -604,10 +604,6 @@ def test_graphtool_multiedge_import(self): g = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g.W[3, 6], 3.0) - # test custom aggregator function - g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - self.assertEqual(g2.W[3, 6], 1.0) - eprop_double = g_gt.new_edge_property("double") # Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 @@ -616,8 +612,8 @@ def test_graphtool_multiedge_import(self): eprop_double[e[1]] = 1.0 g_gt.edge_properties["weight"] = eprop_double - g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - self.assertEqual(g3.W[3, 6], 3.0) + g3 = graphs.Graph.from_graphtool(g_gt) + self.assertEqual(g3.W[3, 6], 9.0) def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly From 2f060539eb78eb3c60da3435c3653efb84358c41 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 17 Oct 2018 21:53:39 +0800 Subject: [PATCH 118/365] Cleaner way of adding the signal --- pygsp/graphs/graph.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5d153017..b77484ed 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -281,12 +281,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): graph = cls(weights) # Adding signals - for s_name in graph_gt.vertex_properties.keys(): - if s_name in graph_gt.vertex_properties.keys(): - signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) - graph.set_signal(signal, s_name) - else: - warnings.warn("{} was not found in the graph_tool graph".format(s_name)) + for signal_name, signal_gt in graph_gt.vertex_properties.items(): + signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) + graph.set_signal(signal, signal_name) return graph @classmethod From b519b65448cadbbc847da914a97e3fe0587c16e7 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 27 Dec 2018 23:53:35 +0800 Subject: [PATCH 119/365] Test environement on travis --- .travis.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6b472238..bf713de7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,8 +28,9 @@ install: script: # - make lint - - make test - - make doc +# - make test +# - make doc + - docker run -v `pwd`:/opt/pygsp cgallay/pygsp make test after_success: - coveralls From 531bd11ce12fc2dfec22fb482b73d372a6270111 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 3 Feb 2019 18:54:54 +0800 Subject: [PATCH 120/365] Bug fix --- pygsp/graphs/graph.py | 2 +- pygsp/tests/test_graphs.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b77484ed..e905a898 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -145,7 +145,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): - signal_dict = {i: signal[i] for i in range(self.n_nodes)} + signal_dict = {i: signal[i] for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8ee7017f..2e7e666f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -652,7 +652,7 @@ def test_networkx_signal_export(self): graph.set_signal(signal1, "signal1") graph.set_signal(signal2, "signal2") graph_nx = graph.to_networkx() - for i in range(graph.n_nodes): + for i in range(graph.N): self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) @@ -710,7 +710,7 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - tested_fmt = ["gml", "gexf"] #"dot", "graphml" + tested_fmt = ["gml", "gexf"] # "dot", "graphml" for fmt in tested_fmt: g.save("bunny." + fmt) From 6b53fed90aba7a28bef712acceac3c30e3e124b4 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 7 Feb 2019 21:23:24 +0800 Subject: [PATCH 121/365] Add auto selection of save and load backend based on the format of the file. --- pygsp/graphs/graph.py | 84 ++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e905a898..0f5ab386 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -287,7 +287,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): return graph @classmethod - def load(cls, path, fmt='auto', lib='networkx'): + def load(cls, path, fmt='auto', backend='networkx'): r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt @@ -297,7 +297,7 @@ def load(cls, path, fmt='auto', lib='networkx'): Where the file is located on the disk. fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} Format in which the graph is encoded. - lib : String + backend : String Python library used in background to load the graph. Supported library are networkx and graph_tool @@ -305,29 +305,36 @@ def load(cls, path, fmt='auto', lib='networkx'): ------- graph : :class:`~pygsp.graphs.Graph` """ + + def load_networkx(saved_path, format): + import networkx as nx + load = getattr(nx, 'read_' + format) + return cls.from_networkx(load(saved_path)) + + def load_graph_tool(saved_path, format): + import graph_tool as gt + graph_gt = gt.load_graph(saved_path, fmt=format) + return cls.from_graphtool(graph_gt) + if fmt == 'auto': fmt = path.split('.')[-1] + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - if fmt in ['graphml', 'gml', 'gexf']: - try: - import networkx as nx - load = getattr(nx, 'read_' + fmt) - return cls.from_networkx(load(path)) - except ModuleNotFoundError: - pass - if fmt in ['graphml', 'gml', 'dot']: - try: - import graph_tool as gt - graph_gt = gt.load_graph(path, fmt=fmt) - return cls.from_graphtool(graph_gt) - except ModuleNotFoundError: - pass - raise ModuleNotFoundError("Please install either networkx or graph_tool") - - def save(self, path, fmt='auto', lib='networkx'): + if backend not in ['networkx', 'graph_tool']: + raise ValueError('Unsupported backend specified {}.'.format(backend)) + + + return locals()['load_' + backend](path, fmt) + + def save(self, path, fmt='auto', backend='auto'): r"""Save the graph into a file Parameters @@ -339,33 +346,36 @@ def save(self, path, fmt='auto', lib='networkx'): the `path` extention when fmt is set to 'auto' Currently supported format are: GML and gpickle. - lib : String + backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool """ + def save_networkx(graph, save_path): + import networkx as nx + graph_nx = graph.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph_nx, save_path) + + def save_graph_tool(graph, save_path): + graph_gt = self.to_graphtool() + graph_gt.save(path, fmt=fmt) + if fmt == 'auto': fmt = path.split('.')[-1] + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - if fmt in ['graphml', 'gml', 'gexf']: - try: - import networkx as nx - graph_nx = self.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph_nx, path) - return None - except ModuleNotFoundError: - pass - if fmt in ['graphml', 'gml', 'dot']: - try: - graph_gt = self.to_graphtool() - graph_gt.save(path, fmt=fmt) - return None - except ModuleNotFoundError: - pass - raise ModuleNotFoundError("Please install either networkx or graph_tool") + if backend not in ['networkx', 'graph_tool']: + raise ValueError('Unsupported backend specified {}.'.format(backend)) + + locals()['save_' + backend](self, path) def set_signal(self, signal, name): r""" From 7fde44795a2d2fc9744ebe592c8d7b29bd5016e2 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 7 Feb 2019 21:27:59 +0800 Subject: [PATCH 122/365] install dependencies for graph-tool on travis --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index bf713de7..6b472238 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,9 +28,8 @@ install: script: # - make lint -# - make test -# - make doc - - docker run -v `pwd`:/opt/pygsp cgallay/pygsp make test + - make test + - make doc after_success: - coveralls From d09bb0a81157349ea83e36b7cf797237ba9c7cd5 Mon Sep 17 00:00:00 2001 From: Junki Ishikawa <69guitar1015@gmail.com> Date: Mon, 18 Feb 2019 17:32:20 +0900 Subject: [PATCH 123/365] Fix extract_components --- pygsp/graphs/graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0f5ab386..da06efd9 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -721,7 +721,8 @@ def extract_components(self): # indices = [] # Assigned but never used while not visited.all(): - stack = set(np.nonzero(~visited)[0]) + # pick a node not visted yet + stack = set(np.nonzero(~visited)[0][[0]]) comp = [] while len(stack): From 01fc3efde4f8586b30c2efd944e8de8835d06c4b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 14:35:18 +0100 Subject: [PATCH 124/365] test for python > 3.5 only --- pygsp/graphs/graph.py | 3 +-- pygsp/tests/test_graphs.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index da06efd9..4fcd9e5b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -287,7 +287,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): return graph @classmethod - def load(cls, path, fmt='auto', backend='networkx'): + def load(cls, path, fmt='auto', backend='auto'): r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt @@ -331,7 +331,6 @@ def load_graph_tool(saved_path, format): if backend not in ['networkx', 'graph_tool']: raise ValueError('Unsupported backend specified {}.'.format(backend)) - return locals()['load_' + backend](path, fmt) def save(self, path, fmt='auto', backend='auto'): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 2e7e666f..f8f683f9 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -11,6 +11,7 @@ import unittest import random import os +import sys import numpy as np import scipy.linalg @@ -710,14 +711,15 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - tested_fmt = ["gml", "gexf"] # "dot", "graphml" - for fmt in tested_fmt: - g.save("bunny." + fmt) - - for fmt in tested_fmt: - graph_loaded = graphs.Graph.load("bunny." + fmt) - np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) - os.remove("bunny." + fmt) + tested_fmt = ["gml", "gexf", "graphml"] + if sys.version_info > (3, 5): + for fmt in tested_fmt: + g.save("bunny." + fmt) + + for fmt in tested_fmt: + graph_loaded = graphs.Graph.load("bunny." + fmt) + np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + os.remove("bunny." + fmt) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 40c5755f10b8fa43a583d2f8920c42972621bdb3 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 17:24:30 +0100 Subject: [PATCH 125/365] Fix save for graph_tool (dot, gml) --- pygsp/graphs/graph.py | 12 +++++++++++- pygsp/tests/test_graphs.py | 24 ++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 4fcd9e5b..32954017 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -250,15 +250,24 @@ def from_graphtool(cls, graph_gt, weight='weight'): graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ + import graph_tool as gt nb_vertex = graph_gt.num_vertices() nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() + if 'vertex_name' in graph_gt.vertex_properties.keys(): + vertex_name = graph_gt.vertex_properties['vertex_name'] + scalar_vertex = graph_gt.new_vertex_property('int') + gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) + # graph_gt.vertex_properties['vertex_name'] = scalar_vertex + graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) try: prop = graph_gt.edge_properties[weight] - edge_weight = prop.get_array() + scalar_prop = graph_gt.new_edge_property('double') + gt.map_property_values(prop, scalar_prop, lambda x: float(x)) + edge_weight = scalar_prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) @@ -348,6 +357,7 @@ def save(self, path, fmt='auto', backend='auto'): backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool + WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. """ def save_networkx(graph, save_path): import networkx as nx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index f8f683f9..b2a943a4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -710,16 +710,32 @@ def test_networkx_signal_import(self): nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): - g = graphs.Bunny() + g = graphs.Sensor(seed=42) tested_fmt = ["gml", "gexf", "graphml"] + filename = "graph." if sys.version_info > (3, 5): for fmt in tested_fmt: - g.save("bunny." + fmt) + g.save(filename + fmt) for fmt in tested_fmt: - graph_loaded = graphs.Graph.load("bunny." + fmt) + graph_loaded = graphs.Graph.load(filename + fmt) np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) - os.remove("bunny." + fmt) + os.remove(filename + fmt) + + fmt = "gml" + + g.save(filename + fmt, backend='graph_tool') + graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') + np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + os.remove(filename + fmt) + + fmt = 'dot' + g = graphs.Sensor(seed=42) + g.save(filename + fmt, backend='graph_tool') + graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') + g = graphs.Sensor(seed=42) + np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + os.remove(filename + fmt) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From ff928dabb4045ad37e6ef709c77035aa53aaa375 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 17:32:51 +0100 Subject: [PATCH 126/365] clean code --- pygsp/graphs/graph.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 32954017..df5e2bea 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -255,12 +255,10 @@ def from_graphtool(cls, graph_gt, weight='weight'): nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) - props_names = graph_gt.edge_properties.keys() if 'vertex_name' in graph_gt.vertex_properties.keys(): vertex_name = graph_gt.vertex_properties['vertex_name'] scalar_vertex = graph_gt.new_vertex_property('int') gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) - # graph_gt.vertex_properties['vertex_name'] = scalar_vertex graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) try: From e892d0ebbe265249f030a5af27162f8863f9f5d2 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 18:32:21 +0100 Subject: [PATCH 127/365] fix verison for test_save_load --- pygsp/tests/test_graphs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b2a943a4..de9a89e1 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -710,10 +710,10 @@ def test_networkx_signal_import(self): nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): - g = graphs.Sensor(seed=42) - tested_fmt = ["gml", "gexf", "graphml"] - filename = "graph." - if sys.version_info > (3, 5): + if sys.version_info >= (3, 6): + g = graphs.Sensor(seed=42) + tested_fmt = ["gml", "gexf", "graphml"] + filename = "graph." for fmt in tested_fmt: g.save(filename + fmt) From fa369bf85fac1d168892b757faca59c7ad785ca5 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 18:59:31 +0100 Subject: [PATCH 128/365] use graph_tool.generation to gen random graph --- pygsp/tests/test_graphs.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index de9a89e1..eb0f6d56 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -619,15 +619,9 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - graph_gt = gt.Graph() - graph_gt.add_vertex(100) + graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) - # insert single random links eprop_double = graph_gt.new_edge_property("double") - for s, t in set(zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100))): - graph_gt.add_edge(graph_gt.vertex(s), graph_gt.vertex(t)) - for e in graph_gt.edges(): eprop_double[e] = random.random() graph_gt.edge_properties["weight"] = eprop_double From 023f4fe6591c2965d813c9eae604e1bdba2d0132 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 19:01:47 +0100 Subject: [PATCH 129/365] remove useless isomorphic testing --- pygsp/tests/test_graphs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index eb0f6d56..a1ca405f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -583,7 +583,6 @@ def test_networkx_import_export(self): g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() - self.assertTrue(nx.is_isomorphic(g_nx, g)) np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) From 605cbb65d5f55245b5f3aa63d4df6fe2b19c5979 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 19:21:09 +0100 Subject: [PATCH 130/365] Convert to float the singal when exporting to networkx --- pygsp/graphs/graph.py | 4 ++-- pygsp/tests/test_graphs.py | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index df5e2bea..54951485 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -145,7 +145,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): - signal_dict = {i: signal[i] for i in range(self.N)} + signal_dict = {i: float(signal[i]) for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx @@ -217,7 +217,7 @@ def from_networkx(cls, graph_nx, weight='weight'): # Add signal previously not present in the dict of signal # Set to zero the value of the signal when not present for a node # in Networkx - for signal in set(signals_name) - set(signals.keys()): + for signal in set(signals_name) - set(signals.keys()): signals[signal] = np.zeros(len(nodelist)) # Set the value of the signal diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a1ca405f..24e0df2e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -704,30 +704,34 @@ def test_networkx_signal_import(self): def test_save_load(self): if sys.version_info >= (3, 6): - g = graphs.Sensor(seed=42) + graph = graphs.Sensor(seed=42) + np.random.seed(42) + signal = np.random.random(graph.N) + graph.set_signal(signal, "signal") tested_fmt = ["gml", "gexf", "graphml"] filename = "graph." for fmt in tested_fmt: - g.save(filename + fmt) + graph.save(filename + fmt) for fmt in tested_fmt: graph_loaded = graphs.Graph.load(filename + fmt) - np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + np.testing.assert_array_equal(graph.W.todense(), graph_loaded.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded.signals['signal']) os.remove(filename + fmt) fmt = "gml" - g.save(filename + fmt, backend='graph_tool') + graph.save(filename + fmt, backend='graph_tool') graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) os.remove(filename + fmt) fmt = 'dot' - g = graphs.Sensor(seed=42) - g.save(filename + fmt, backend='graph_tool') + graph = graphs.Sensor(seed=42) + graph.save(filename + fmt, backend='graph_tool') graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - g = graphs.Sensor(seed=42) - np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + graph = graphs.Sensor(seed=42) + np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) os.remove(filename + fmt) From 267e37fa94381f6d27f40bfbda5ea22d4999014b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 21:52:41 +0100 Subject: [PATCH 131/365] skip graph tool tests for python2.7 --- pygsp/tests/test_graphs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 24e0df2e..25a4d774 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -589,12 +589,16 @@ def test_networkx_import_export(self): def test_graphtool_export_import(self): # Export to graph tool and reimport to PyGSP directly # The exported graph is a simple one without an associated Signal + if sys.version_info < (3, 0): + return None # skip test for python 2.7 g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) def test_graphtool_multiedge_import(self): + if sys.version_info < (3, 0): + return None # skip test for python2.7 # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) @@ -618,6 +622,8 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal + if sys.version_info < (3, 0): + return None # skip test for python2.7 graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) eprop_double = graph_gt.new_edge_property("double") @@ -664,6 +670,8 @@ def test_graphtool_signal_export(self): self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) def test_graphtool_signal_import(self): + if sys.version_info < (3, 0): + return None # skip test for python2.7 g_gt = gt.Graph() g_gt.add_vertex(10) From a022f8d054ebb235b19fad75382b6438161cb1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:14:53 +0100 Subject: [PATCH 132/365] Update pygsp/graphs/graph.py Accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 54951485..406797e6 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -132,7 +132,7 @@ def to_networkx(self): The weight are stored as an edge attribute under named `weight` The signals are stored as node attributes under the same name as define in PyGSP - :func:`~pygsp.graphs.Graph.set_signal`. + adding them with :meth:`set_signal`. Returns ------- From f2684e63747b74e27d813cdd63059d553a96a269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:16:40 +0100 Subject: [PATCH 133/365] Update pygsp/graphs/graph.py Accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 406797e6..72964390 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -131,7 +131,7 @@ def to_networkx(self): r"""Export the graph to an `Networkx `_ object The weight are stored as an edge attribute under named `weight` - The signals are stored as node attributes under the same name as define in PyGSP + The signals are stored as node attributes under the name given when adding them with :meth:`set_signal`. Returns From 393d18540a0baabd05952edf8832edf9d089ff25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:17:15 +0100 Subject: [PATCH 134/365] Update pygsp/graphs/graph.py accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 72964390..49069395 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -130,7 +130,7 @@ def __repr__(self, limit=None): def to_networkx(self): r"""Export the graph to an `Networkx `_ object - The weight are stored as an edge attribute under named `weight` + The weights are stored as an edge attribute under the name `weight`. The signals are stored as node attributes under the name given when adding them with :meth:`set_signal`. From 68f0af28efb6fadcf584d9a8ed8c456efa62d676 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 20 Feb 2019 19:18:45 +0100 Subject: [PATCH 135/365] remove support for dot export and import --- pygsp/graphs/graph.py | 34 ++++++++++++--------------- pygsp/tests/test_graphs.py | 48 +++++++++++++++++++------------------- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 49069395..015768b9 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -145,6 +145,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): + # networkx can't work with numpy floats so we convert the singal into python float signal_dict = {i: float(signal[i]) for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx @@ -255,17 +256,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) - if 'vertex_name' in graph_gt.vertex_properties.keys(): - vertex_name = graph_gt.vertex_properties['vertex_name'] - scalar_vertex = graph_gt.new_vertex_property('int') - gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) - graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) - try: prop = graph_gt.edge_properties[weight] - scalar_prop = graph_gt.new_edge_property('double') - gt.map_property_values(prop, scalar_prop, lambda x: float(x)) - edge_weight = scalar_prop.get_array() + edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) @@ -302,7 +295,7 @@ def load(cls, path, fmt='auto', backend='auto'): ---------- path : String Where the file is located on the disk. - fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} + fmt : {'graphml', 'gml', 'gexf', 'auto'} Format in which the graph is encoded. backend : String Python library used in background to load the graph. @@ -332,11 +325,13 @@ def load_graph_tool(saved_path, format): else: backend = 'graph_tool' - if fmt not in ['graphml', 'gml', 'gexf', 'dot']: - raise ValueError('Unsupported format {}.'.format(fmt)) + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {}.'.format(backend)) + raise ValueError( + 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) return locals()['load_' + backend](path, fmt) @@ -351,7 +346,7 @@ def save(self, path, fmt='auto', backend='auto'): Format in which the graph will be encoded. The format is guessed from the `path` extention when fmt is set to 'auto' Currently supported format are: - GML and gpickle. + ['graphml', 'gml', 'gexf'] backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool @@ -364,8 +359,8 @@ def save_networkx(graph, save_path): save(graph_nx, save_path) def save_graph_tool(graph, save_path): - graph_gt = self.to_graphtool() - graph_gt.save(path, fmt=fmt) + graph_gt = graph.to_graphtool() + graph_gt.save(save_path, fmt=fmt) if fmt == 'auto': fmt = path.split('.')[-1] @@ -376,11 +371,12 @@ def save_graph_tool(graph, save_path): else: backend = 'graph_tool' - if fmt not in ['graphml', 'gml', 'gexf', 'dot']: - raise ValueError('Unsupported format {}.'.format(fmt)) + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {}.'.format(backend)) + raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) locals()['save_' + backend](self, path) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 25a4d774..78ec3434 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -716,31 +716,31 @@ def test_save_load(self): np.random.seed(42) signal = np.random.random(graph.N) graph.set_signal(signal, "signal") - tested_fmt = ["gml", "gexf", "graphml"] - filename = "graph." - for fmt in tested_fmt: - graph.save(filename + fmt) - for fmt in tested_fmt: - graph_loaded = graphs.Graph.load(filename + fmt) - np.testing.assert_array_equal(graph.W.todense(), graph_loaded.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded.signals['signal']) - os.remove(filename + fmt) - - fmt = "gml" - - graph.save(filename + fmt, backend='graph_tool') - graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) - os.remove(filename + fmt) - - fmt = 'dot' - graph = graphs.Sensor(seed=42) - graph.save(filename + fmt, backend='graph_tool') - graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - graph = graphs.Sensor(seed=42) - np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) - os.remove(filename + fmt) + # save + nx_gt = ['gml', 'graphml'] + all_files = [] + for fmt in nx_gt: + all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] + graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_nx.{}".format(fmt), backend='networkx') + graph.save("graph_nx.{}".format('gexf'), backend='networkx') + all_files += ["graph_nx.{}".format('gexf')] + + # load + for filename in all_files: + if not "_gt" in filename: + graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') + np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) + if not ".gexf" in filename: + graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) + np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) + + # clean + for filename in all_files: + os.remove(filename) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 957eabe6f7aaf7a2139c926e25ccea67a2bf7f00 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 21 Feb 2019 11:47:43 +0100 Subject: [PATCH 136/365] use graph_tool.spectral.adjacency --- pygsp/graphs/graph.py | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 015768b9..00dceac3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -252,33 +252,10 @@ def from_graphtool(cls, graph_gt, weight='weight'): The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ import graph_tool as gt - nb_vertex = graph_gt.num_vertices() - nb_edges = graph_gt.num_edges() - weights = np.zeros(shape=(nb_vertex, nb_vertex)) - - try: - prop = graph_gt.edge_properties[weight] - edge_weight = prop.get_array() - if edge_weight is None: - warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") - edge_weight = np.ones(nb_edges) - - except KeyError: - warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" - .format(weight)) - edge_weight = np.ones(nb_edges) - - # merging multi-edge - merged_edge_weight = [] - for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) - for e in merged_edge_weight: - weights[e[0], e[1]] = e[2] - # When the graph is not directed the opposit edge as to be added too. - if not graph_gt.is_directed(): - for e in merged_edge_weight: - weights[e[1], e[0]] = e[2] - graph = cls(weights) + import graph_tool.spectral + + weight_property = graph_gt.edge_properties.get(weight, None) + graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) # Adding signals for signal_name, signal_gt in graph_gt.vertex_properties.items(): @@ -324,7 +301,7 @@ def load_graph_tool(saved_path, format): backend = 'networkx' else: backend = 'graph_tool' - + supported_format = ['graphml', 'gml', 'gexf'] if fmt not in supported_format: raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) From 696397ef30d1d0b121bfe61c73ef07ab0f4a0f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:01:41 +0100 Subject: [PATCH 137/365] RandomRing: use a kernel --- pygsp/graphs/randomring.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 6d9de235..4ca97bb7 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -47,6 +47,10 @@ def __init__(self, N=64, seed=None, **kwargs): W[0, N-1] = weight_end W = utils.symmetrize(W, method='triu') + # TODO: why this kernel ? It empirically produces eigenvectors closer + # to the sines and cosines. + W.data = 1 / W.data + angle = position * 2 * np.pi coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} From fd917d2c753e5c308754430b2d3f46eb34b012f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:03:53 +0100 Subject: [PATCH 138/365] RandomRing: cleanup --- pygsp/graphs/randomring.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 4ca97bb7..6d7805b2 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -8,7 +8,7 @@ class RandomRing(Graph): - r"""Ring graph with randomly sampled nodes. + r"""Ring graph with randomly sampled vertices. Parameters ---------- @@ -34,27 +34,33 @@ def __init__(self, N=64, seed=None, **kwargs): self.seed = seed rs = np.random.RandomState(seed) - position = np.sort(rs.uniform(size=N), axis=0) + angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) + self.angles = angles - weight = N * np.diff(position) - weight_end = N * (1 + position[0] - position[-1]) + rows = range(0, N-1) + cols = range(1, N) + weights = np.diff(angles) - inds_i = np.arange(0, N-1) - inds_j = np.arange(1, N) + # Close the loop. + rows = np.concatenate((rows, [0])) + cols = np.concatenate((cols, [N-1])) + weights = np.concatenate((weights, [2*np.pi + angles[0] - angles[-1]])) - W = sparse.csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) - W = W.tolil() - W[0, N-1] = weight_end + W = sparse.coo_matrix((weights, (rows, cols)), shape=(N, N)) W = utils.symmetrize(W, method='triu') + # Width as the expected angle. All angles are equal to that value when + # the ring is uniformly sampled. + width = 2 * np.pi / N + assert (W.data.mean() - width) < 1e-10 # TODO: why this kernel ? It empirically produces eigenvectors closer # to the sines and cosines. - W.data = 1 / W.data + W.data = width / W.data - angle = position * 2 * np.pi - coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) + coords = np.stack([np.cos(angles), np.sin(angles)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} + # TODO: save angle and 2D position as graph signals super(RandomRing, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) From a10062e4889fad10f5f3d205fd642f7caea55834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:32:41 +0100 Subject: [PATCH 139/365] StochasticBlockModel: allow to indefinitely try to get a connected graph --- pygsp/graphs/stochasticblockmodel.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 4f90e362..45592523 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -41,8 +41,9 @@ class StochasticBlockModel(Graph): Allow self loops if True (default is False). connected : bool Force the graph to be connected (default is False). - n_try : int - Maximum number of trials to get a connected graph (default is 10). + n_try : int or None + Maximum number of trials to get a connected graph. If None, it will try + forever. seed : int Seed for the random number generator (for reproducible graphs). @@ -108,7 +109,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, # Along the lines of np.random.uniform(size=(N, N)) < p. # Or similar to sparse.random(N, N, p, data_rvs=lambda n: np.ones(n)). - for nb_iter in range(n_try): + while (n_try is None) or (n_try > 0): nb_row, nb_col = 0, 0 csr_data, csr_i, csr_j = [], [], [] @@ -132,14 +133,15 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break - else: - self.W = W - if self.is_connected(recompute=True): - break - if nb_iter == n_try - 1: - raise ValueError('The graph could not be connected after {} ' - 'trials. Increase the connection probability ' - 'or the number of trials.'.format(n_try)) + self.W = W + if self.is_connected(recompute=True): + break + if n_try is not None: + n_try -= 1 + if connected and n_try == 0: + raise ValueError('The graph could not be connected after {} ' + 'trials. Increase the connection probability ' + 'or the number of trials.'.format(self.n_try)) self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} From 1d925741ef44b6107e84b53954877969d39d4bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:33:20 +0100 Subject: [PATCH 140/365] test: avoid failed tests due to non connected graphs --- pygsp/tests/test_graphs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 78ec3434..b260f304 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -512,10 +512,10 @@ def test_stochasticblockmodel(self): graphs.StochasticBlockModel(N=100, directed=False) graphs.StochasticBlockModel(N=100, self_loops=True) graphs.StochasticBlockModel(N=100, self_loops=False) - graphs.StochasticBlockModel(N=100, connected=True, n_try=100) + graphs.StochasticBlockModel(N=100, connected=True, seed=42) graphs.StochasticBlockModel(N=100, connected=False) self.assertRaises(ValueError, graphs.StochasticBlockModel, - N=100, p=0, q=0, connected=True, n_try=100) + N=100, p=0, q=0, connected=True) def test_airfoil(self): graphs.Airfoil() @@ -528,8 +528,8 @@ def test_davidsensornet(self): def test_erdosreny(self): graphs.ErdosRenyi(N=100, connected=False, directed=False) graphs.ErdosRenyi(N=100, connected=False, directed=True) - graphs.ErdosRenyi(N=100, connected=True, n_try=100, directed=False) - graphs.ErdosRenyi(N=100, connected=True, n_try=100, directed=True) + graphs.ErdosRenyi(N=100, connected=True, directed=False, seed=42) + graphs.ErdosRenyi(N=100, connected=True, directed=True, seed=42) G = graphs.ErdosRenyi(N=100, p=1, self_loops=True) self.assertEqual(G.W.nnz, 100**2) From b101d6e320e21a0ce6f6b7498a86b36c1bf4e7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:47:57 +0100 Subject: [PATCH 141/365] merge all the extra requirements in a single dev requirement --- .readthedocs.yml | 3 +-- .travis.yml | 4 ++-- CONTRIBUTING.rst | 2 +- postBuild | 2 +- setup.py | 17 ++++++----------- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index c6869239..5cbbda17 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -5,5 +5,4 @@ python: version: 3 pip_install: true extra_requirements: - - alldeps - - doc + - dev diff --git a/.travis.yml b/.travis.yml index 6b472238..c93253f7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,8 +23,8 @@ addons: - libflann-dev install: - - pip install -U --upgrade-strategy eager .[alldeps,test,doc] - # Update dependencies (e.g. numpy formatting changed in v1.14). + - pip install --upgrade --upgrade-strategy eager .[dev] + # Upgrade to test with the latest version of our dependencies. script: # - make lint diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 63604dcf..f2ed7790 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -14,7 +14,7 @@ The package can be set up (ideally in a virtual environment) for local development with the following:: $ git clone https://github.com/epfl-lts2/pygsp.git - $ pip install -U -e pygsp[alldeps,test,doc,pkg] + $ pip install -U -e pygsp[dev] You can improve or add functionality in the ``pygsp`` folder, along with corresponding unit tests in ``pygsp/tests/test_*.py`` (with reasonable diff --git a/postBuild b/postBuild index 6eb53a4d..e1530e8e 100755 --- a/postBuild +++ b/postBuild @@ -1,3 +1,3 @@ #!/bin/bash # Tell https://mybinder.org to simply install the package. -pip install .[alldeps] +pip install .[dev] diff --git a/setup.py b/setup.py index 6bdd0b57..de0da4a8 100644 --- a/setup.py +++ b/setup.py @@ -31,8 +31,9 @@ 'scipy', ], extras_require={ - # Optional dependencies for some functionalities. - 'alldeps': ( + # Optional dependencies for development. Some bring additional + # functionalities, others are for testing, documentation, or packaging. + 'dev': [ # Import and export. 'networkx', # Construct patch graphs from images. @@ -51,22 +52,16 @@ 'PyQt5; python_version >= "3.5"', # No source package for PyQt5 on PyPI, fall back to PySide. 'PySide; python_version < "3.5"', - ), - # Testing dependencies. - 'test': [ + # Run the tests. 'flake8', 'coverage', 'coveralls', - ], - # Dependencies to build the documentation. - 'doc': [ + # Build the documentation. 'sphinx', 'numpydoc', 'sphinxcontrib-bibtex', 'sphinx-rtd-theme', - ], - # Dependencies to build and upload packages. - 'pkg': [ + # Build and upload packages. 'wheel', 'twine', ], From d6f2c90859bc22c338264577674998a7122c639a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 13:21:56 +0100 Subject: [PATCH 142/365] tests: rename suite from documentation to reference --- pygsp/tests/test_docstrings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/tests/test_docstrings.py b/pygsp/tests/test_docstrings.py index b8837a64..d8f7ff45 100644 --- a/pygsp/tests/test_docstrings.py +++ b/pygsp/tests/test_docstrings.py @@ -32,11 +32,12 @@ def setup(doctest): 'np': numpy, } + # Docstrings from reference documentation. -suite_documentation = test_docstrings('pygsp', '.py', setup) +suite_reference = test_docstrings('pygsp', '.py', setup) # Docstrings from tutorials. # No setup to not forget imports. suite_tutorials = test_docstrings('.', '.rst') -suite = unittest.TestSuite([suite_documentation, suite_tutorials]) +suite = unittest.TestSuite([suite_reference, suite_tutorials]) From 438cf747d0a66f8c05215ecf35980613a3459bb9 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 13:49:12 +0100 Subject: [PATCH 143/365] Add examples --- pygsp/graphs/graph.py | 46 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 00dceac3..ce56f681 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -137,6 +137,13 @@ def to_networkx(self): Returns ------- graph_nx : :py:class:`networkx.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> nx_graph = graph.to_networkx() + >>> nx_graph.number_of_nodes() + 1130 """ import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( @@ -159,6 +166,12 @@ def to_graphtool(self): Returns ------- graph_gt : :py:class:`graph_tool.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> gt_graph = graph.to_graphtool() + >>> weight_property = gt_graph.edge_properties["weight"] """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) @@ -204,6 +217,12 @@ def from_networkx(cls, graph_nx, weight='weight'): Returns ------- graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> import networkx as nx + >>> nx_graph = nx.random_geometric_graph(200, 0.125) + >>> graph = graphs.Graph.from_networkx(nx_graph) """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array @@ -250,10 +269,16 @@ def from_graphtool(cls, graph_gt, weight='weight'): ------- graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` + + Examples + -------- + >>> from graph_tool.all import Graph + >>> gt_graph = Graph() + >>> graph = graphs.Graph.from_graphtool(gt_graph) """ import graph_tool as gt import graph_tool.spectral - + weight_property = graph_gt.edge_properties.get(weight, None) graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) @@ -281,6 +306,10 @@ def load(cls, path, fmt='auto', backend='auto'): Returns ------- graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> graph = graphs.Graph.load('logo.graphml') """ def load_networkx(saved_path, format): @@ -301,7 +330,7 @@ def load_graph_tool(saved_path, format): backend = 'networkx' else: backend = 'graph_tool' - + supported_format = ['graphml', 'gml', 'gexf'] if fmt not in supported_format: raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) @@ -328,6 +357,11 @@ def save(self, path, fmt='auto', backend='auto'): Python library used in background to save the graph. Supported library are networkx and graph_tool WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') """ def save_networkx(graph, save_path): import networkx as nx @@ -367,6 +401,14 @@ def set_signal(self, signal, name): An array mapping from node to his value. For example the value of the signal at node i is signal[i] name : String Name associated to the signal. + + Examples + -------- + >>> graph = graphs.Logo() + >>> DELTAS = [20, 30, 1090] + >>> signal = np.zeros(graph.N) + >>> signal[DELTAS] = 1 + >>> graph.set_signal(signal, 'diffusion') """ if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") From d97fec62446641256f84c3e28b63222263cd898e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 14:59:22 +0100 Subject: [PATCH 144/365] fix doctest --- pygsp/graphs/graph.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ce56f681..cf873a91 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -142,8 +142,9 @@ def to_networkx(self): -------- >>> graph = graphs.Logo() >>> nx_graph = graph.to_networkx() - >>> nx_graph.number_of_nodes() + >>> print(nx_graph.number_of_nodes()) 1130 + """ import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( @@ -172,6 +173,7 @@ def to_graphtool(self): >>> graph = graphs.Logo() >>> gt_graph = graph.to_graphtool() >>> weight_property = gt_graph.edge_properties["weight"] + """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) @@ -221,8 +223,9 @@ def from_networkx(cls, graph_nx, weight='weight'): Examples -------- >>> import networkx as nx - >>> nx_graph = nx.random_geometric_graph(200, 0.125) + >>> nx_graph = nx.star_graph(200) >>> graph = graphs.Graph.from_networkx(nx_graph) + """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array @@ -274,7 +277,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): -------- >>> from graph_tool.all import Graph >>> gt_graph = Graph() + >>> gt_graph.add_vertex(10) >>> graph = graphs.Graph.from_graphtool(gt_graph) + """ import graph_tool as gt import graph_tool.spectral @@ -309,7 +314,9 @@ def load(cls, path, fmt='auto', backend='auto'): Examples -------- + >>> graphs.Logo().save('logo.graphml') >>> graph = graphs.Graph.load('logo.graphml') + """ def load_networkx(saved_path, format): @@ -362,6 +369,7 @@ def save(self, path, fmt='auto', backend='auto'): -------- >>> graph = graphs.Logo() >>> graph.save('logo.graphml') + """ def save_networkx(graph, save_path): import networkx as nx @@ -409,6 +417,7 @@ def set_signal(self, signal, name): >>> signal = np.zeros(graph.N) >>> signal[DELTAS] = 1 >>> graph.set_signal(signal, 'diffusion') + """ if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") From a70093152fa1a875642150b3f05079f8c926f602 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 16:37:26 +0100 Subject: [PATCH 145/365] fix expcted nothing error --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index cf873a91..9c1ab9cf 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -277,7 +277,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): -------- >>> from graph_tool.all import Graph >>> gt_graph = Graph() - >>> gt_graph.add_vertex(10) + >>> _ = gt_graph.add_vertex(10) >>> graph = graphs.Graph.from_graphtool(gt_graph) """ From 9f5f13e38c76d74b17d00cabbd19a280842ace8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 16:26:56 +0100 Subject: [PATCH 146/365] rings need at least 3 vertices --- pygsp/graphs/randomring.py | 4 ++++ pygsp/graphs/ring.py | 4 ++++ pygsp/tests/test_graphs.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 6d7805b2..637246e2 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -37,6 +37,10 @@ def __init__(self, N=64, seed=None, **kwargs): angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) self.angles = angles + if N < 3: + # Asymmetric graph needed for 2 as 2 distances connect them. + raise ValueError('There should be at least 3 vertices.') + rows = range(0, N-1) cols = range(1, N) weights = np.diff(angles) diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 1c549619..4935b217 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -30,6 +30,10 @@ def __init__(self, N=64, k=1, **kwargs): self.k = k + if N < 3: + # Asymmetric graph needed for 2 as 2 distances connect them. + raise ValueError('There should be at least 3 vertices.') + if 2*k > N: raise ValueError('Too many neighbors requested.') diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b260f304..6403a54c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -489,6 +489,8 @@ def test_randomregular(self): def test_ring(self): graphs.Ring() graphs.Ring(N=32, k=16) + self.assertRaises(ValueError, graphs.Ring, 2) + self.assertRaises(ValueError, graphs.Ring, 5, k=3) def test_community(self): graphs.Community() @@ -544,6 +546,8 @@ def test_path(self): def test_randomring(self): graphs.RandomRing() + self.assertRaises(ValueError, graphs.RandomRing, 2) + self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2]) def test_swissroll(self): graphs.SwissRoll(srtype='uniform') From fa9c1aee1f706d78832387ef4d98bf05c97214ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 16:27:29 +0100 Subject: [PATCH 147/365] RandomRing: allow user to pass angles --- pygsp/graphs/randomring.py | 15 ++++++++++++--- pygsp/tests/test_graphs.py | 4 ++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 637246e2..5527665f 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -14,6 +14,8 @@ class RandomRing(Graph): ---------- N : int Number of vertices. + angles : array-like, optional + The angular coordinate, in :math:`[0, 2\pi]`, of the vertices. seed : int Seed for the random number generator (for reproducible graphs). @@ -29,12 +31,19 @@ class RandomRing(Graph): """ - def __init__(self, N=64, seed=None, **kwargs): + def __init__(self, N=64, angles=None, seed=None, **kwargs): self.seed = seed - rs = np.random.RandomState(seed) - angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) + if angles is None: + rs = np.random.RandomState(seed) + angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) + else: + angles = np.asanyarray(angles) + angles.sort() # Need to be sorted to take the difference. + N = len(angles) + if np.any(angles < 0) or np.any(angles >= 2*np.pi): + raise ValueError('Angles should be in [0, 2 pi]') self.angles = angles if N < 3: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6403a54c..8f25f9c4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -546,8 +546,12 @@ def test_path(self): def test_randomring(self): graphs.RandomRing() + G = graphs.RandomRing(angles=[0, 2, 1]) + self.assertEqual(G.N, 3) self.assertRaises(ValueError, graphs.RandomRing, 2) self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2]) + self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, 7]) + self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, -1]) def test_swissroll(self): graphs.SwissRoll(srtype='uniform') From cf6588987766270803f6c4b0b69eeb93c935789d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 18:12:46 +0100 Subject: [PATCH 148/365] readme: remove outdated installation note --- README.rst | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.rst b/README.rst index 7f0b8776..523f4c0b 100644 --- a/README.rst +++ b/README.rst @@ -100,9 +100,6 @@ The PyGSP is available on PyPI:: $ pip install pygsp -Note that you will need a recent version of ``pip`` and ``setuptools``. Please -run ``pip install --upgrade pip setuptools`` if you get any installation error. - The PyGSP is available on `conda-forge `_:: $ conda install -c conda-forge pygsp From 9d3f9c3515d000cf5b80de47e98a12694ab07dc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 18:13:54 +0100 Subject: [PATCH 149/365] readme: important information stands out --- README.rst | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 523f4c0b..c95ceb1c 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,14 @@ PyGSP: Graph Signal Processing in Python ======================================== +The PyGSP is a Python package to ease +`Signal Processing on Graphs `_. +The documentation is available on +`Read the Docs `_ +and development takes place on +`GitHub `_. +A (mostly unmaintained) `Matlab version `_ exists. + +-----------------------------------+ | |doc| |pypi| |conda| |binder| | +-----------------------------------+ @@ -31,13 +39,6 @@ PyGSP: Graph Signal Processing in Python .. |conda| image:: https://anaconda.org/conda-forge/pygsp/badges/installer/conda.svg :target: https://anaconda.org/conda-forge/pygsp -The PyGSP is a Python package to ease -`Signal Processing on Graphs `_. -The documentation is available on -`Read the Docs `_ -and development takes place on -`GitHub `_. -A (mostly unmaintained) `Matlab version `_ exists. The PyGSP facilitates a wide variety of operations on graphs, like computing their Fourier basis, filtering or interpolating signals, plotting graphs, From 074cd5432ab7dd9941dde724e708fbb33c393b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Feb 2019 02:27:13 +0100 Subject: [PATCH 150/365] readme: install from AUR --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index c95ceb1c..0c1b1c49 100644 --- a/README.rst +++ b/README.rst @@ -105,6 +105,12 @@ The PyGSP is available on `conda-forge `_:: + + $ git clone https://aur.archlinux.org/python-pygsp.git + $ cd python-pygsp + $ makepkg -csi + Contributing ------------ From 0dfac7c65b3a9eeae828e1a3f44efb0cc77f5c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Feb 2019 23:13:25 +0100 Subject: [PATCH 151/365] improve is_connected --- pygsp/graphs/graph.py | 78 ++++++++++++++++++---------- pygsp/graphs/stochasticblockmodel.py | 1 + 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9c1ab9cf..13ed48de 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -603,15 +603,10 @@ def subgraph(self, ind): return Graph(sub_W) def is_connected(self, recompute=False): - r"""Check the strong connectivity of the graph (cached). + r"""Check if the graph is connected (cached). - It uses DFS travelling on graph to ensure that each node is visited. - For undirected graphs, starting at any vertex and trying to access all - others is enough. - For directed graphs, one needs to check that a random vertex is - accessible by all others - and can access all others. Thus, we can transpose the adjacency matrix - and compute again with the same starting point in both phases. + A graph is connected if and only if there exists a (directed) path + between any two vertices. Parameters ---------- @@ -621,40 +616,67 @@ def is_connected(self, recompute=False): Returns ------- connected : bool - True if the graph is connected. + True if the graph is connected, False otherwise. + + Notes + ----- + + For undirected graphs, starting at a vertex and trying to visit all the + others is enough. + For directed graphs, one needs to check that a vertex can both be + visited by all the others and visit all the others. Examples -------- - >>> from scipy import sparse - >>> W = sparse.rand(10, 10, 0.2) - >>> G = graphs.Graph(W=W) - >>> connected = G.is_connected() + + Connected graph: + + >>> adjacency = np.array([ + ... [0, 3, 0, 0], + ... [3, 0, 4, 0], + ... [0, 4, 0, 2], + ... [0, 0, 2, 0], + ... ]) + >>> graph = graphs.Graph(adjacency) + >>> graph.is_connected() + True + + Disconnected graph: + + >>> adjacency = np.array([ + ... [0, 3, 0, 0], + ... [3, 0, 4, 0], + ... [0, 0, 0, 2], + ... [0, 0, 2, 0], + ... ]) + >>> graph = graphs.Graph(adjacency) + >>> graph.is_connected() + False + """ if hasattr(self, '_connected') and not recompute: return self._connected + adjacencies = [self.W] if self.is_directed(recompute=recompute): - adj_matrices = [self.A, self.A.T] - else: - adj_matrices = [self.A] + adjacencies.append(self.W.T) - for adj_matrix in adj_matrices: - visited = np.zeros(self.A.shape[0], dtype=bool) + for adjacency in adjacencies: + visited = np.zeros(self.n_vertices, dtype=np.bool) stack = set([0]) - while len(stack): - v = stack.pop() - if not visited[v]: - visited[v] = True + while stack: + vertex = stack.pop() - # Add indices of nodes not visited yet and accessible from - # v - stack.update(set([idx - for idx in adj_matrix[v, :].nonzero()[1] - if not visited[idx]])) + if visited[vertex]: + continue + visited[vertex] = True + + neighbors = adjacency[vertex].nonzero()[1] + stack.update(neighbors) - if not visited.all(): + if not np.all(visited): self._connected = False return self._connected diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 45592523..8e010747 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -134,6 +134,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break self.W = W + self.n_vertices = W.shape[0] if self.is_connected(recompute=True): break if n_try is not None: From fa597f18721c3febbf053a2a1c602d26c3ee43c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 00:58:56 +0100 Subject: [PATCH 152/365] test connectedness --- pygsp/tests/test_graphs.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8f25f9c4..89016158 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -59,6 +59,36 @@ def test_degree(self): np.testing.assert_allclose(G.d, 3 * np.ones([4])) np.testing.assert_allclose(G.dw, 3 * 0.3) + def test_is_connected(self): + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 2], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), False) + self.assertEqual(graph.is_connected(), True) + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 0], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), True) + self.assertEqual(graph.is_connected(), False) + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 0], + [0, 0, 0], + ]) + self.assertEqual(graph.is_directed(), False) + self.assertEqual(graph.is_connected(), False) + graph = graphs.Graph([ + [0, 1, 0], + [0, 0, 2], + [3, 0, 0], + ]) + self.assertEqual(graph.is_directed(), True) + self.assertEqual(graph.is_connected(), True) + def test_laplacian(self): adjacency = np.array([ From 52bce1c30dc6f3e41406f4d678839d2fb95f81b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:04:32 +0100 Subject: [PATCH 153/365] accept adjacencies as list of lists --- pygsp/graphs/difference.py | 8 +++---- pygsp/graphs/graph.py | 46 ++++++++++++++++++++------------------ pygsp/tests/test_graphs.py | 12 +++++----- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 40fba222..b82bdccf 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -95,11 +95,11 @@ def compute_differential_operator(self): The difference operator is an incidence matrix. Example with a undirected graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() @@ -116,11 +116,11 @@ def compute_differential_operator(self): Example with a directed graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 13ed48de..fd9dd6c2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -65,22 +65,24 @@ def __init__(self, W, lap_type='combinatorial', coords=None, plotting={}): self.logger = utils.build_logger(__name__) - if len(W.shape) != 2 or W.shape[0] != W.shape[1]: - raise ValueError('W has incorrect shape {}'.format(W.shape)) - # CSR sparse matrices are the most efficient for matrix multiplication. # They are the sole sparse matrix type to support eliminate_zeros(). if sparse.isspmatrix_csr(W): self.W = W + elif sparse.isspmatrix(W): + self.W = W.tocsr() else: - self.W = sparse.csr_matrix(W) + self.W = sparse.csr_matrix(np.asanyarray(W)) + + if len(self.W.shape) != 2 or self.W.shape[0] != self.W.shape[1]: + raise ValueError('W has incorrect shape {}'.format(self.W.shape)) + + self.n_vertices = self.W.shape[0] # Don't keep edges of 0 weight. Otherwise Ne will not correspond to the # real number of edges. Problematic when e.g. plotting. self.W.eliminate_zeros() - self.n_vertices = W.shape[0] - # TODO: why would we ever want this? # For large matrices it slows the graph construction by a factor 100. # self.W = sparse.lil_matrix(self.W) @@ -631,24 +633,24 @@ def is_connected(self, recompute=False): Connected graph: - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.is_connected() True Disconnected graph: - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 0, 0, 2], ... [0, 0, 2, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.is_connected() False @@ -708,22 +710,22 @@ def is_directed(self, recompute=False): Directed graph: - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.is_directed() True Undirected graph: - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.is_directed() False @@ -833,11 +835,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of an undirected graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() @@ -852,11 +854,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of a directed graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() @@ -1176,11 +1178,11 @@ def get_edge_list(self): Edge list of a directed graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) @@ -1188,11 +1190,11 @@ def get_edge_list(self): Edge list of an undirected graph. - >>> adjacency = np.array([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ]) + ... ] >>> graph = graphs.Graph(adjacency) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 89016158..3e133f4b 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -167,22 +167,22 @@ def check_lmax(graph, lmax): check_lmax(graph, lmax=value*n_nodes) # Regular bipartite graph (bound is tight). - adjacency = np.array([ + adjacency = [ [0, 0, 1, 1], [0, 0, 1, 1], [1, 1, 0, 0], [1, 1, 0, 0], - ]) + ] graph = graphs.Graph(adjacency, lap_type='combinatorial') check_lmax(graph, lmax=4) # Bipartite graph (bound is tight). - adjacency = np.array([ + adjacency = [ [0, 0, 1, 1], [0, 0, 1, 0], [1, 1, 0, 0], [1, 0, 0, 0], - ]) + ] graph = graphs.Graph(adjacency, lap_type='normalized') check_lmax(graph, lmax=2) @@ -280,8 +280,8 @@ def test_incidence_nx(graph): np.testing.assert_equal(incidence_pg, incidence_nx.toarray()) for graph in [graphs.Graph(np.zeros((n_vertices, n_vertices))), graphs.Graph(np.identity(n_vertices)), - graphs.Graph(np.array([[0, 0.8], [0.8, 0]])), - graphs.Graph(np.array([[1.3, 0], [0.4, 0.5]])), + graphs.Graph([[0, 0.8], [0.8, 0]]), + graphs.Graph([[1.3, 0], [0.4, 0.5]]), graphs.ErdosRenyi(n_vertices, directed=False, seed=42), graphs.ErdosRenyi(n_vertices, directed=True, seed=42)]: for lap_type in ['combinatorial', 'normalized']: From 242a1a7cb6b4bfc448e86c9a1eb46bed42005c49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:10:00 +0100 Subject: [PATCH 154/365] accept signals as array_like --- pygsp/filters/filter.py | 6 ++---- pygsp/graphs/difference.py | 29 +++++++++++++++-------------- pygsp/graphs/fourier.py | 12 ++++-------- pygsp/graphs/graph.py | 13 +++++++++++-- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 5abc1169..6452b4da 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -179,7 +179,7 @@ def filter(self, s, method='chebyshev', order=30): Parameters ---------- - s : ndarray + s : array_like Graph signals, a tensor of shape ``(N_NODES, N_SIGNALS, N_FEATURES)``, where ``N_NODES`` is the number of nodes in the graph, ``N_SIGNALS`` the number of independent signals you want to @@ -265,9 +265,7 @@ def filter(self, s, method='chebyshev', order=30): True """ - if s.shape[0] != self.G.N: - raise ValueError('First dimension should be the number of nodes ' - 'G.N = {}, got {}.'.format(self.G.N, s.shape)) + s = self.G._check_signal(s) # TODO: not in self.Nin (Nf = Nin x Nout). if s.ndim == 1 or s.shape[-1] not in [1, self.Nf]: diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index b82bdccf..cdd83f7a 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -199,7 +199,7 @@ def grad(self, x): Parameters ---------- - x : ndarray + x : array_like Signal of length :attr:`n_vertices` living on the vertices. Returns @@ -217,27 +217,26 @@ def grad(self, x): -------- >>> graph = graphs.Path(4, directed=False, lap_type='combinatorial') >>> graph.compute_differential_operator() - >>> graph.grad(np.array([0, 2, 4, 2])) + >>> graph.grad([0, 2, 4, 2]) array([ 2., 2., -2.]) >>> graph = graphs.Path(4, directed=True, lap_type='combinatorial') >>> graph.compute_differential_operator() - >>> graph.grad(np.array([0, 2, 4, 2])) + >>> graph.grad([0, 2, 4, 2]) array([ 1.41421356, 1.41421356, -1.41421356]) >>> graph = graphs.Path(4, directed=False, lap_type='normalized') >>> graph.compute_differential_operator() - >>> graph.grad(np.array([0, 2, 4, 2])) + >>> graph.grad([0, 2, 4, 2]) array([ 1.41421356, 1.41421356, -0.82842712]) >>> graph = graphs.Path(4, directed=True, lap_type='normalized') >>> graph.compute_differential_operator() - >>> graph.grad(np.array([0, 2, 4, 2])) + >>> graph.grad([0, 2, 4, 2]) array([ 1.41421356, 1.41421356, -0.82842712]) """ - if self.N != x.shape[0]: - raise ValueError('Signal length should be the number of nodes.') + x = self._check_signal(x) return self.D.T.dot(x) def div(self, y): @@ -273,7 +272,7 @@ def div(self, y): Parameters ---------- - y : ndarray + y : array_like Signal of length :attr:`n_edges` living on the edges. Returns @@ -291,25 +290,27 @@ def div(self, y): -------- >>> graph = graphs.Path(4, directed=False, lap_type='combinatorial') >>> graph.compute_differential_operator() - >>> graph.div(np.array([2, -2, 0])) + >>> graph.div([2, -2, 0]) array([-2., 4., -2., 0.]) >>> graph = graphs.Path(4, directed=True, lap_type='combinatorial') >>> graph.compute_differential_operator() - >>> graph.div(np.array([2, -2, 0])) + >>> graph.div([2, -2, 0]) array([-1.41421356, 2.82842712, -1.41421356, 0. ]) >>> graph = graphs.Path(4, directed=False, lap_type='normalized') >>> graph.compute_differential_operator() - >>> graph.div(np.array([2, -2, 0])) + >>> graph.div([2, -2, 0]) array([-2. , 2.82842712, -1.41421356, 0. ]) >>> graph = graphs.Path(4, directed=True, lap_type='normalized') >>> graph.compute_differential_operator() - >>> graph.div(np.array([2, -2, 0])) + >>> graph.div([2, -2, 0]) array([-2. , 2.82842712, -1.41421356, 0. ]) """ - if self.Ne != y.shape[0]: - raise ValueError('Signal length should be the number of edges.') + y = np.asanyarray(y) + if y.shape[0] != self.Ne: + raise ValueError('First dimension must be the number of edges ' + 'G.Ne = {}, got {}.'.format(self.Ne, y.shape)) return self.D.dot(y) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 43170fe6..9091b97d 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -200,7 +200,7 @@ def gft(self, s): Parameters ---------- - s : ndarray + s : array_like Graph signal in the vertex domain. Returns @@ -219,9 +219,7 @@ def gft(self, s): True """ - if s.shape[0] != self.N: - raise ValueError('First dimension should be the number of nodes ' - 'G.N = {}, got {}.'.format(self.N, s.shape)) + s = self._check_signal(s) U = np.conjugate(self.U) # True Hermitian. (Although U is often real.) return np.tensordot(U, s, ([0], [0])) @@ -237,7 +235,7 @@ def igft(self, s_hat): Parameters ---------- - s_hat : ndarray + s_hat : array_like Graph signal in the Fourier domain. Returns @@ -256,7 +254,5 @@ def igft(self, s_hat): True """ - if s_hat.shape[0] != self.N: - raise ValueError('First dimension should be the number of nodes ' - 'G.N = {}, got {}.'.format(self.N, s_hat.shape)) + s_hat = self._check_signal(s_hat) return np.tensordot(self.U, s_hat, ([1], [0])) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index fd9dd6c2..3d657f48 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -915,6 +915,14 @@ def compute_laplacian(self, lap_type='combinatorial'): else: raise ValueError('Unknown Laplacian type {}'.format(lap_type)) + def _check_signal(self, s): + r"""Check if signal is valid.""" + s = np.asanyarray(s) + if s.shape[0] != self.N: + raise ValueError('First dimension must be the number of vertices ' + 'G.N = {}, got {}.'.format(self.N, s.shape)) + return s + def dirichlet_energy(self, x): r"""Compute the Dirichlet energy of a signal defined on the vertices. @@ -951,7 +959,7 @@ def dirichlet_energy(self, x): Examples -------- >>> graph = graphs.Path(5, directed=False) - >>> signal = np.array([0, 2, 2, 4, 4]) + >>> signal = [0, 2, 2, 4, 4] >>> graph.dirichlet_energy(signal) 8.0 >>> # The Dirichlet energy is indeed the squared norm of the gradient. @@ -960,7 +968,7 @@ def dirichlet_energy(self, x): array([2., 0., 2., 0.]) >>> graph = graphs.Path(5, directed=True) - >>> signal = np.array([0, 2, 2, 4, 4]) + >>> signal = [0, 2, 2, 4, 4] >>> graph.dirichlet_energy(signal) 4.0 >>> # The Dirichlet energy is indeed the squared norm of the gradient. @@ -969,6 +977,7 @@ def dirichlet_energy(self, x): array([1.41421356, 0. , 1.41421356, 0. ]) """ + x = self._check_signal(x) return x.T.dot(self.L.dot(x)) @property From d5af8d4dbaffa8ab51a62d8256a3ace26d993843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:28:59 +0100 Subject: [PATCH 155/365] some more array_like --- pygsp/filters/filter.py | 7 +++++-- pygsp/graphs/graph.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 6452b4da..1fcbcf00 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -116,7 +116,7 @@ def evaluate(self, x): Parameters ---------- - x : ndarray + x : array_like Graph frequencies at which to evaluate the filter. Returns @@ -138,6 +138,7 @@ def evaluate(self, x): [] """ + x = np.asanyarray(x) # Avoid to copy data as with np.array([g(x) for g in self._kernels]). y = np.empty([self.Nf] + list(x.shape)) for i, kernel in enumerate(self._kernels): @@ -410,7 +411,7 @@ def estimate_frame_bounds(self, x=None): Parameters ---------- - x : ndarray + x : array_like Graph frequencies at which to evaluate the filter bank `g(x)`. The default is `x = np.linspace(0, G.lmax, 1000)`. The exact bounds are given by evaluating the filter bank at the @@ -497,6 +498,8 @@ def estimate_frame_bounds(self, x=None): """ if x is None: x = np.linspace(0, self.G.lmax, 1000) + else: + x = np.asanyarray(x) sum_filters = np.sum(self.evaluate(x)**2, axis=0) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 3d657f48..787a0a03 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -944,7 +944,7 @@ def dirichlet_energy(self, x): Parameters ---------- - x : ndarray + x : array_like Signal of length :attr:`n_vertices` living on the vertices. Returns From dc802267077bc549f7f0eae2508b7a1fb2aaec65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:31:08 +0100 Subject: [PATCH 156/365] array-like => array_like --- pygsp/filters/approximations.py | 6 +++--- pygsp/filters/mexicanhat.py | 2 +- pygsp/filters/simpletight.py | 2 +- pygsp/graphs/graph.py | 2 +- pygsp/graphs/randomring.py | 2 +- pygsp/plotting.py | 8 ++++---- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/filters/approximations.py b/pygsp/filters/approximations.py index 28b57acd..2c383760 100644 --- a/pygsp/filters/approximations.py +++ b/pygsp/filters/approximations.py @@ -121,16 +121,16 @@ def cheby_rect(G, bounds, signal, **kwargs): Parameters ---------- G : Graph - bounds : array-like + bounds : array_like The bounds of the pass-band filter - signal : array-like + signal : array_like Signal to filter order : int (optional) Order of the Chebyshev polynomial (default: 30) Returns ------- - r : array-like + r : array_like Result of the filtering """ diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 92740aa6..e0c43c56 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -32,7 +32,7 @@ class MexicanHat(Filter): lpfactor : float Low-pass factor. lmin=lmax/lpfactor will be used to determine scales. The scaling function will be created to fill the low-pass gap. - scales : array-like + scales : array_like Scales to be used. By default, initialized with :func:`pygsp.utils.compute_log_scales`. normalize : bool diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index c5aad96b..4e26bf06 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -19,7 +19,7 @@ class SimpleTight(Filter): G : graph Nf : int Number of filters to cover the interval [0, lmax]. - scales : array-like + scales : array_like Scales to be used. Defaults to log scale. Examples diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 787a0a03..306d5a53 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -485,7 +485,7 @@ def set_coordinates(self, kind='spring', **kwargs): Parameters ---------- - kind : string or array-like + kind : string or array_like Kind of coordinates to generate. It controls the position of the nodes when plotting the graph. Can either pass an array of size Nx2 or Nx3 to set the coordinates manually or the name of a layout diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 5527665f..a28a11c0 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -14,7 +14,7 @@ class RandomRing(Graph): ---------- N : int Number of vertices. - angles : array-like, optional + angles : array_like, optional The angular coordinate, in :math:`[0, 2\pi]`, of the vertices. seed : int Seed for the random number generator (for reproducible graphs). diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 56fcc6bf..659020a5 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -295,13 +295,13 @@ def _plot_graph(G, vertex_color, vertex_size, highlight, Parameters ---------- - vertex_color : array-like or color + vertex_color : array_like or color Signal to plot as vertex color (length is the number of vertices). If None, vertex color is set to `graph.plotting['vertex_color']`. Alternatively, a color can be set in any format accepted by matplotlib. Each vertex color can by specified by an RGB(A) array of dimension `n_vertices` x 3 (or 4). - vertex_size : array-like or int + vertex_size : array_like or int Signal to plot as vertex size (length is the number of vertices). Vertex size ranges from 0.5 to 2 times `graph.plotting['vertex_size']`. If None, vertex size is set to `graph.plotting['vertex_size']`. @@ -315,7 +315,7 @@ def _plot_graph(G, vertex_color, vertex_size, highlight, Whether to draw edges in addition to vertices. Default to True if less than 10,000 edges to draw. Note that drawing many edges can be slow. - edge_color : array-like or color + edge_color : array_like or color Signal to plot as edge color (length is the number of edges). Edge color is given by `graph.plotting['edge_color']` and transparency ranges from 0.2 to 0.9. @@ -324,7 +324,7 @@ def _plot_graph(G, vertex_color, vertex_size, highlight, Each edge color can by specified by an RGB(A) array of dimension `n_edges` x 3 (or 4). Only available with the matplotlib backend. - edge_width : array-like or int + edge_width : array_like or int Signal to plot as edge width (length is the number of edges). Edge width ranges from 0.5 to 2 times `graph.plotting['edge_width']`. If None, edge width is set to `graph.plotting['edge_width']`. From f6d8c8acb2e1950584e5600b45c73d806e49010f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:36:17 +0100 Subject: [PATCH 157/365] prefer asanyarray over asarray --- pygsp/filters/meyer.py | 2 +- pygsp/filters/wave.py | 2 +- pygsp/graphs/graph.py | 6 +++--- pygsp/graphs/stochasticblockmodel.py | 4 ++-- pygsp/plotting.py | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 4c03c703..e54f5314 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -61,7 +61,7 @@ def kernel(x, kernel_type): * meyer scaling function kernel: supported on [0,4/3] """ - x = np.asarray(x) + x = np.asanyarray(x) l1 = 2/3. l2 = 4/3. # 2*l1 diff --git a/pygsp/filters/wave.py b/pygsp/filters/wave.py index 60137c24..551e1b4e 100644 --- a/pygsp/filters/wave.py +++ b/pygsp/filters/wave.py @@ -115,7 +115,7 @@ def __init__(self, G, time=10, speed=1): raise ValueError('If both parameters are iterable, ' 'they should have the same length.') - if np.any(np.asarray(speed) >= 2): + if np.any(np.asanyarray(speed) >= 2): raise ValueError('The wave propagation speed should be in [0, 2[') def kernel(x, time, speed): diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 306d5a53..e0f8788a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -505,7 +505,7 @@ def set_coordinates(self, kind='spring', **kwargs): """ if not isinstance(kind, str): - coords = np.asarray(kind).squeeze() + coords = np.asanyarray(kind).squeeze() check_1d = (coords.ndim == 1) check_2d_3d = (coords.ndim == 2) and (2 <= coords.shape[1] <= 3) if coords.shape[0] != self.N or not (check_1d or check_2d_3d): @@ -1267,7 +1267,7 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], pos_arr = np.random.RandomState(seed).uniform(size=(self.N, dim)) pos_arr = pos_arr * dom_size + center for i in range(self.N): - pos_arr[i] = np.asarray(pos[i]) + pos_arr[i] = np.asanyarray(pos[i]) if k is None and len(fixed) > 0: # We must adjust k by domain size for layouts that are not near 1x1 @@ -1319,7 +1319,7 @@ def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): # enforce minimum distance of 0.01 distance = np.where(distance < 0.01, 0.01, distance) # the adjacency matrix row - Ai = np.asarray(A[i, :].toarray()) + Ai = A[i, :].toarray() # displacement "force" displacement[:, i] += \ (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 8e010747..6951e36d 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -80,7 +80,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if M is None: self.p = p - p = np.asarray(p) + p = np.asanyarray(p) if p.size == 1: p = p * np.ones(k) if p.shape != (k,): @@ -90,7 +90,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if q is None: q = 0.3 / k self.q = q - q = np.asarray(q) + q = np.asanyarray(q) if q.size == 1: q = q * np.ones((k, k)) if q.shape != (k, k): diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 659020a5..cbc8fb9d 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -432,14 +432,14 @@ def is_color(color): limits = [0, 0] colorbar = False else: - vertex_color = np.asarray(vertex_color).squeeze() + vertex_color = np.asanyarray(vertex_color).squeeze() check_shape(vertex_color, 'Vertex color', G.n_vertices, many=(G.coords.ndim == 1)) if vertex_size is None: vertex_size = G.plotting['vertex_size'] elif not np.isscalar(vertex_size): - vertex_size = np.asarray(vertex_size).squeeze() + vertex_size = np.asanyarray(vertex_size).squeeze() check_shape(vertex_size, 'Vertex size', G.n_vertices) vertex_size = G.plotting['vertex_size'] * 4 * normalize(vertex_size)**2 @@ -449,7 +449,7 @@ def is_color(color): if edge_color is None: edge_color = (G.plotting['edge_color'],) elif not is_color(edge_color): - edge_color = np.asarray(edge_color).squeeze() + edge_color = np.asanyarray(edge_color).squeeze() check_shape(edge_color, 'Edge color', G.n_edges) edge_color = 0.9 * normalize(edge_color) edge_color = [ From 4e25e043a9ecf16d4d92a84f89c7a1e83a098707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:39:55 +0100 Subject: [PATCH 158/365] improve and test subgraph --- pygsp/graphs/graph.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e0f8788a..ab52653a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -575,34 +575,41 @@ def set_coordinates(self, kind='spring', **kwargs): else: raise ValueError('Unexpected argument kind={}.'.format(kind)) - def subgraph(self, ind): - r"""Create a subgraph given indices. + def subgraph(self, vertices): + r"""Create a subgraph from a list of vertices. Parameters ---------- - ind : list - Nodes to keep + vertices : list + List of vertices to keep. Returns ------- - sub_G : Graph - Subgraph + subgraph : :class:`Graph` + Subgraph. Examples -------- - >>> W = np.arange(16).reshape(4, 4) - >>> G = graphs.Graph(W) - >>> ind = [1, 3] - >>> sub_G = G.subgraph(ind) + >>> adjacency = [ + ... [0, 3, 0, 0], + ... [3, 0, 4, 0], + ... [0, 4, 0, 2], + ... [0, 0, 2, 0], + ... ] + >>> graph = graphs.Graph(adjacency) + >>> graph = graph.subgraph([0, 2, 1]) + >>> graph.W.toarray() + array([[0, 0, 3], + [0, 0, 4], + [3, 4, 0]], dtype=int64) """ - if not isinstance(ind, list) and not isinstance(ind, np.ndarray): - raise TypeError('The indices must be a list or a ndarray.') - - # N = len(ind) # Assigned but never used - - sub_W = self.W.tocsr()[ind, :].tocsc()[:, ind] - return Graph(sub_W) + adjacency = self.W[vertices, :][:, vertices] + try: + coords = self.coords[vertices] + except AttributeError: + coords = None + return Graph(adjacency, self.lap_type, coords, self.plotting) def is_connected(self, recompute=False): r"""Check if the graph is connected (cached). From d746ab66f3fb78cfba9ecdafc16e896026119154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:43:53 +0100 Subject: [PATCH 159/365] improve and test is_directed --- pygsp/graphs/graph.py | 8 ++------ pygsp/tests/test_graphs.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ab52653a..20208770 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -696,7 +696,7 @@ def is_directed(self, recompute=False): r"""Check if the graph has directed edges (cached). In this framework, we consider that a graph is directed if and - only if its weight matrix is non symmetric. + only if its weight matrix is not symmetric. Parameters ---------- @@ -708,10 +708,6 @@ def is_directed(self, recompute=False): directed : bool True if the graph is directed. - Notes - ----- - Can also be used to check if a matrix is symmetrical - Examples -------- @@ -741,7 +737,7 @@ def is_directed(self, recompute=False): if hasattr(self, '_directed') and not recompute: return self._directed - self._directed = np.abs(self.W - self.W.T).sum() != 0 + self._directed = (self.W != self.W.T).nnz != 0 return self._directed def extract_components(self): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 3e133f4b..38440f1a 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -89,6 +89,22 @@ def test_is_connected(self): self.assertEqual(graph.is_directed(), True) self.assertEqual(graph.is_connected(), True) + def test_is_directed(self): + graph = graphs.Graph([ + [0, 3, 0, 0], + [3, 0, 4, 0], + [0, 4, 0, 2], + [0, 0, 2, 0], + ]) + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(), False) + graph.W[0, 1] = 0 + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(recompute=True), True) + graph.W[1, 0] = 0 + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(recompute=True), False) + def test_laplacian(self): adjacency = np.array([ From 841d4c4385352015d3c36d4836df0ce58d0abd33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 02:08:01 +0100 Subject: [PATCH 160/365] simplify --- pygsp/graphs/difference.py | 10 ++++----- pygsp/graphs/graph.py | 45 +++++++++++++++----------------------- pygsp/tests/test_graphs.py | 6 ++--- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index cdd83f7a..df2e3d92 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -95,12 +95,11 @@ def compute_differential_operator(self): The difference operator is an incidence matrix. Example with a undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() @@ -116,12 +115,11 @@ def compute_differential_operator(self): Example with a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 20208770..63e71435 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -590,13 +590,12 @@ def subgraph(self, vertices): Examples -------- - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph = graph.subgraph([0, 2, 1]) >>> graph.W.toarray() array([[0, 0, 3], @@ -640,25 +639,23 @@ def is_connected(self, recompute=False): Connected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_connected() True Disconnected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 0, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_connected() False @@ -713,23 +710,21 @@ def is_directed(self, recompute=False): Directed graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_directed() True Undirected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_directed() False @@ -838,12 +833,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of an undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2., -2., 0.], @@ -857,12 +851,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2. , -2. , 0. ], @@ -1190,24 +1183,22 @@ def get_edge_list(self): Edge list of a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1, 1], [1, 0, 2], [3, 3, 4]) Edge list of an undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1], [1, 2], [3, 4]) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 38440f1a..3afb269e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -107,7 +107,7 @@ def test_is_directed(self): def test_laplacian(self): - adjacency = np.array([ + G = graphs.Graph([ [0, 3, 0, 1], [3, 0, 1, 0], [0, 1, 0, 3], @@ -119,20 +119,18 @@ def test_laplacian(self): [+0, -1, +4, -3], [-1, +0, -3, +4], ]) - G = graphs.Graph(adjacency) self.assertFalse(G.is_directed()) G.compute_laplacian('combinatorial') np.testing.assert_allclose(G.L.toarray(), laplacian) G.compute_laplacian('normalized') np.testing.assert_allclose(G.L.toarray(), laplacian/4) - adjacency = np.array([ + G = graphs.Graph([ [0, 6, 0, 1], [0, 0, 0, 0], [0, 2, 0, 3], [1, 0, 3, 0], ]) - G = graphs.Graph(adjacency) self.assertTrue(G.is_directed()) G.compute_laplacian('combinatorial') np.testing.assert_allclose(G.L.toarray(), laplacian) From a860487e504bcdf6b3cf2e6d332a91b15dfeef51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 02:47:47 +0100 Subject: [PATCH 161/365] fix python 2.7 --- pygsp/graphs/graph.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 63e71435..1cb25b72 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -591,16 +591,16 @@ def subgraph(self, vertices): Examples -------- >>> graph = graphs.Graph([ - ... [0, 3, 0, 0], - ... [3, 0, 4, 0], - ... [0, 4, 0, 2], - ... [0, 0, 2, 0], + ... [0., 3., 0., 0.], + ... [3., 0., 4., 0.], + ... [0., 4., 0., 2.], + ... [0., 0., 2., 0.], ... ]) >>> graph = graph.subgraph([0, 2, 1]) >>> graph.W.toarray() - array([[0, 0, 3], - [0, 0, 4], - [3, 4, 0]], dtype=int64) + array([[0., 0., 3.], + [0., 0., 4.], + [3., 4., 0.]]) """ adjacency = self.W[vertices, :][:, vertices] From 138b45d659db1be4d3cbc1757964d8bbe7c4b35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 04:07:06 +0100 Subject: [PATCH 162/365] improve graph construction from adjacency --- pygsp/graphs/__init__.py | 2 +- pygsp/graphs/airfoil.py | 2 +- pygsp/graphs/barabasialbert.py | 2 +- pygsp/graphs/comet.py | 2 +- pygsp/graphs/community.py | 2 +- pygsp/graphs/davidsensornet.py | 2 +- pygsp/graphs/fullconnected.py | 2 +- pygsp/graphs/graph.py | 469 ++++++---------------- pygsp/graphs/grid2d.py | 2 +- pygsp/graphs/logo.py | 2 +- pygsp/graphs/lowstretchtree.py | 2 +- pygsp/graphs/minnesota.py | 2 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 2 +- pygsp/graphs/nngraphs/nngraph.py | 5 + pygsp/graphs/path.py | 2 +- pygsp/graphs/randomregular.py | 2 +- pygsp/graphs/randomring.py | 2 +- pygsp/graphs/ring.py | 2 +- pygsp/graphs/stochasticblockmodel.py | 2 +- pygsp/graphs/swissroll.py | 2 +- pygsp/graphs/torus.py | 2 +- pygsp/reduction.py | 6 +- pygsp/tests/test_graphs.py | 51 ++- pygsp/tests/test_utils.py | 98 ----- 24 files changed, 190 insertions(+), 477 deletions(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 90b94ca9..852259d3 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -75,9 +75,9 @@ .. autosummary:: - Graph.check_weights Graph.is_connected Graph.is_directed + Graph.has_loops Plotting -------- diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 538da2be..0bbc8140 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -34,5 +34,5 @@ def __init__(self, **kwargs): "limits": np.array([-1e-4, 1.01*data['x'].max(), -1e-4, 1.01*data['y'].max()])} - super(Airfoil, self).__init__(W=W, coords=coords, plotting=plotting, + super(Airfoil, self).__init__(W, coords=coords, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 1a70a2a9..0f2a11bb 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -63,7 +63,7 @@ def __init__(self, N=1000, m0=1, m=1, seed=None, **kwargs): W[elem, i] = 1 W[i, elem] = 1 - super(BarabasiAlbert, self).__init__(W=W, **kwargs) + super(BarabasiAlbert, self).__init__(W, **kwargs) def _get_extra_repr(self): return dict(m0=self.m0, m=self.m, seed=self.seed) diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 045565ca..b716b1d1 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -59,7 +59,7 @@ def __init__(self, N=32, k=12, **kwargs): np.min(tmpcoords[:, 1]), np.max(tmpcoords[:, 1])])} - super(Comet, self).__init__(W=W, coords=tmpcoords, + super(Comet, self).__init__(W, coords=tmpcoords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 2d5271dc..b10cf4d4 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -236,7 +236,7 @@ def __init__(self, for key, value in {'Nc': Nc, 'info': info}.items(): setattr(self, key, value) - super(Community, self).__init__(W=W, coords=coords, **kwargs) + super(Community, self).__init__(W, coords=coords, **kwargs) def _get_extra_repr(self): attrs = {'Nc': self.Nc, diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index e6c2b1d9..84a3d9c9 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -57,7 +57,7 @@ def __init__(self, N=64, seed=None, **kwargs): plotting = {"limits": [0, 1, 0, 1]} - super(DavidSensorNet, self).__init__(W=W, coords=coords, + super(DavidSensorNet, self).__init__(W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index eed61310..0f4c1dee 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -31,4 +31,4 @@ def __init__(self, N=10, **kwargs): W = np.ones((N, N)) - np.identity(N) plotting = {'limits': np.array([-1, 1, -1, 1])} - super(FullConnected, self).__init__(W=W, plotting=plotting, **kwargs) + super(FullConnected, self).__init__(W, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1cb25b72..94467d1b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -14,79 +14,116 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): r"""Base graph class. - * Provide a common interface (and implementation) to graph objects. - * Can be instantiated to construct custom graphs from a weight matrix. + * Instantiate it to construct a graph from a (weighted) adjacency matrix. + * Provide a common interface (and implementation) for graph objects. * Initialize attributes for derived classes. Parameters ---------- - W : sparse matrix or ndarray - The weight matrix which encodes the graph. - lap_type : 'combinatorial', 'normalized' - The type of Laplacian to be computed by :func:`compute_laplacian` - (default is 'combinatorial'). - coords : ndarray - Vertices coordinates (default is None). + adjacency : sparse matrix or array_like + The (weighted) adjacency matrix of size n_vertices by n_vertices that + encodes the graph. + The data is copied except if it is a sparse matrix in CSR format. + lap_type : {'combinatorial', 'normalized'} + The kind of Laplacian to be computed by :meth:`compute_laplacian`. + coords : array_like + A matrix of size n_vertices by d that represents the coordinates of the + vertices in a d-dimensional embedding space. plotting : dict Plotting parameters. Attributes ---------- - N : int - the number of nodes / vertices in the graph. - Ne : int - the number of edges / links in the graph, i.e. connections between - nodes. - W : sparse matrix - the weight matrix which contains the weights of the connections. - It is represented as an N-by-N matrix of floats. - :math:`W_{i,j} = 0` means that there is no direct connection from - i to j. - L : sparse matrix - the graph Laplacian, an N-by-N matrix computed from W. + n_vertices or N : int + The number of vertices (nodes) in the graph. + n_edges or Ne : int + The number of edges (links) in the graph. + W : :class:`scipy.sparse.csr_matrix` + The adjacency matrix that contains the weights of the edges. + It is represented as an n_vertices by n_vertices matrix, where + :math:`W_{i,j}` is the weight of the edge :math:`(v_i, v_j)` from + vertex :math:`v_i` to vertex :math:`v_j`. :math:`W_{i,j} = 0` means + that there is no direct connection. + L : :class:`scipy.sparse.csr_matrix` + The graph Laplacian, an N-by-N matrix computed from W. lap_type : 'normalized', 'combinatorial' - the kind of Laplacian that was computed by :func:`compute_laplacian`. - coords : ndarray - vertices coordinates in 2D or 3D space. Used for plotting only. Default - is None. + The kind of Laplacian that was computed by :func:`compute_laplacian`. + coords : :class:`numpy.ndarray` + Vertices coordinates in 2D or 3D space. Used for plotting only. plotting : dict - plotting parameters. - signals : dict (String -> numpy.array) - Signals attached to the graph. + Plotting parameters. Examples -------- - >>> W = np.arange(4).reshape(2, 2) - >>> G = graphs.Graph(W) + + Define a simple graph. + + >>> graph = graphs.Graph([ + ... [0., 2., 0.], + ... [2., 0., 5.], + ... [0., 5., 0.], + ... ]) + >>> graph + Graph(n_vertices=3, n_edges=2) + >>> graph.n_vertices, graph.n_edges + (3, 2) + >>> graph.W.toarray() + array([[0., 2., 0.], + [2., 0., 5.], + [0., 5., 0.]]) + >>> graph.d + array([1, 2, 1]) + >>> graph.dw + array([2., 7., 5.]) + >>> graph.L.toarray() + array([[ 2., -2., 0.], + [-2., 7., -5.], + [ 0., -5., 5.]]) + + Add some coordinates to plot it. + + >>> import matplotlib.pyplot as plt + >>> graph.set_coordinates([ + ... [0, 0], + ... [0, 1], + ... [1, 0], + ... ]) + >>> fig, ax = graph.plot() """ - def __init__(self, W, lap_type='combinatorial', coords=None, plotting={}): + def __init__(self, adjacency, lap_type='combinatorial', coords=None, + plotting={}): self.logger = utils.build_logger(__name__) + if not sparse.isspmatrix(adjacency): + adjacency = np.asanyarray(adjacency) + + if (adjacency.ndim != 2) or (adjacency.shape[0] != adjacency.shape[1]): + raise ValueError('Adjacency: must be a square matrix.') + # CSR sparse matrices are the most efficient for matrix multiplication. # They are the sole sparse matrix type to support eliminate_zeros(). - if sparse.isspmatrix_csr(W): - self.W = W - elif sparse.isspmatrix(W): - self.W = W.tocsr() - else: - self.W = sparse.csr_matrix(np.asanyarray(W)) + self.W = sparse.csr_matrix(adjacency, copy=False) - if len(self.W.shape) != 2 or self.W.shape[0] != self.W.shape[1]: - raise ValueError('W has incorrect shape {}'.format(self.W.shape)) + if np.isnan(self.W.sum()): + raise ValueError('Adjacency: there is a Not a Number (NaN).') + if np.isinf(self.W.sum()): + raise ValueError('Adjacency: there is an infinite value.') + if self.has_loops(): + self.logger.warning('Adjacency: there are self-loops ' + '(non-zeros on the diagonal). ' + 'The Laplacian will not see them.') + if (self.W < 0).nnz != 0: + self.logger.warning('Adjacency: there are negative edge weights.') self.n_vertices = self.W.shape[0] - # Don't keep edges of 0 weight. Otherwise Ne will not correspond to the - # real number of edges. Problematic when e.g. plotting. + # Don't keep edges of 0 weight. Otherwise n_edges will not correspond + # to the real number of edges. Problematic when plotting. self.W.eliminate_zeros() - # TODO: why would we ever want this? - # For large matrices it slows the graph construction by a factor 100. - # self.W = sparse.lil_matrix(self.W) - # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. if self.is_directed(): @@ -96,12 +133,10 @@ def __init__(self, W, lap_type='combinatorial', coords=None, plotting={}): off_diagonal = self.W.nnz - diagonal self.n_edges = off_diagonal // 2 + diagonal - self.check_weights() - self.compute_laplacian(lap_type) if coords is not None: - self.coords = coords + self.coords = np.asanyarray(coords) self.plotting = {'vertex_size': 100, 'vertex_color': (0.12, 0.47, 0.71, 0.5), @@ -129,301 +164,6 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self): - r"""Export the graph to an `Networkx `_ object - - The weights are stored as an edge attribute under the name `weight`. - The signals are stored as node attributes under the name given when - adding them with :meth:`set_signal`. - - Returns - ------- - graph_nx : :py:class:`networkx.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> nx_graph = graph.to_networkx() - >>> print(nx_graph.number_of_nodes()) - 1130 - - """ - import networkx as nx - graph_nx = nx.from_scipy_sparse_matrix( - self.W, create_using=nx.DiGraph() - if self.is_directed() else nx.Graph(), - edge_attribute='weight') - - for name, signal in self.signals.items(): - # networkx can't work with numpy floats so we convert the singal into python float - signal_dict = {i: float(signal[i]) for i in range(self.N)} - nx.set_node_attributes(graph_nx, signal_dict, name) - return graph_nx - - def to_graphtool(self): - r"""Export the graph to an `Graph tool `_ object - - The weights of the graph are stored in a `property maps `_ under the name `weight` - - Returns - ------- - graph_gt : :py:class:`graph_tool.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> gt_graph = graph.to_graphtool() - >>> weight_property = gt_graph.edge_properties["weight"] - - """ - import graph_tool - graph_gt = graph_tool.Graph(directed=self.is_directed()) - v_in, v_out, weights = self.get_edge_list() - graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - weight_type_str = utils.numpy2graph_tool_type(weights.dtype) - if weight_type_str is None: - raise ValueError("Type {} for the weights is not supported" - .format(str(weights.dtype))) - edge_weight = graph_gt.new_edge_property(weight_type_str) - edge_weight.a = weights - graph_gt.edge_properties['weight'] = edge_weight - for name in self.signals: - edge_type_str = utils.numpy2graph_tool_type(weights.dtype) - if edge_type_str is None: - raise ValueError("Type {} from signal {} is not supported" - .format(str(self.signals[name].dtype), name)) - vprop_double = graph_gt.new_vertex_property(edge_type_str) - vprop_double.get_array()[:] = self.signals[name] - graph_gt.vertex_properties[name] = vprop_double - return graph_gt - - @classmethod - def from_networkx(cls, graph_nx, weight='weight'): - r"""Build a graph from a Networkx object. - - The nodes are ordered according to method `nodes()` from networkx - - When a node attribute is not present for node a value of zero is assign - to the corresponding signal on that node. - - When the networkx graph is an instance of :py:class:`networkx.MultiGraph`, - multiple edge are aggregated by summation. - - Parameters - ---------- - graph_nx : :py:class:`networkx.Graph` - A networkx instance of a graph - weight : (string or None optional (default=’weight’)) - The edge attribute that holds the numerical value used for the edge weight. - If None then all edge weights are 1. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> import networkx as nx - >>> nx_graph = nx.star_graph(200) - >>> graph = graphs.Graph.from_networkx(nx_graph) - - """ - import networkx as nx - # keep a consistent order of nodes for the agency matrix and the signal array - nodelist = graph_nx.nodes() - adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) - graph = cls(adjacency) - # Adding the signals - signals = dict() - for i, node in enumerate(nodelist): - signals_name = graph_nx.nodes[node].keys() - - # Add signal previously not present in the dict of signal - # Set to zero the value of the signal when not present for a node - # in Networkx - for signal in set(signals_name) - set(signals.keys()): - signals[signal] = np.zeros(len(nodelist)) - - # Set the value of the signal - for signal in signals_name: - signals[signal][i] = graph_nx.nodes[node][signal] - - graph.signals = signals - return graph - - @classmethod - def from_graphtool(cls, graph_gt, weight='weight'): - r"""Build a graph from a graph tool object. - - When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. - - Parameters - ---------- - graph_gt : :py:class:`graph_tool.Graph` - Graph tool object - weight : string - Name of the `property `_ - to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. - On the other hand if the property is found but not set for a specific edge the weight of zero will be set - therefore for single edge this will result in a none existing edge. If you want to set to a default value please - use `set_value `_ - from the graph_tool object. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - The weight of the graph are loaded from the edge property named ``edge_prop_name`` - - Examples - -------- - >>> from graph_tool.all import Graph - >>> gt_graph = Graph() - >>> _ = gt_graph.add_vertex(10) - >>> graph = graphs.Graph.from_graphtool(gt_graph) - - """ - import graph_tool as gt - import graph_tool.spectral - - weight_property = graph_gt.edge_properties.get(weight, None) - graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) - - # Adding signals - for signal_name, signal_gt in graph_gt.vertex_properties.items(): - signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) - graph.set_signal(signal, signal_name) - return graph - - @classmethod - def load(cls, path, fmt='auto', backend='auto'): - r"""Load a graph from a file using networkx for import. - The format is guessed from path, or can be specified by fmt - - Parameters - ---------- - path : String - Where the file is located on the disk. - fmt : {'graphml', 'gml', 'gexf', 'auto'} - Format in which the graph is encoded. - backend : String - Python library used in background to load the graph. - Supported library are networkx and graph_tool - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> graphs.Logo().save('logo.graphml') - >>> graph = graphs.Graph.load('logo.graphml') - - """ - - def load_networkx(saved_path, format): - import networkx as nx - load = getattr(nx, 'read_' + format) - return cls.from_networkx(load(saved_path)) - - def load_graph_tool(saved_path, format): - import graph_tool as gt - graph_gt = gt.load_graph(saved_path, fmt=format) - return cls.from_graphtool(graph_gt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError( - 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - return locals()['load_' + backend](path, fmt) - - def save(self, path, fmt='auto', backend='auto'): - r"""Save the graph into a file - - Parameters - ---------- - path : String - Where to save file on the disk. - fmt : String - Format in which the graph will be encoded. The format is guessed from - the `path` extention when fmt is set to 'auto' - Currently supported format are: - ['graphml', 'gml', 'gexf'] - backend : String - Python library used in background to save the graph. - Supported library are networkx and graph_tool - WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. - - Examples - -------- - >>> graph = graphs.Logo() - >>> graph.save('logo.graphml') - - """ - def save_networkx(graph, save_path): - import networkx as nx - graph_nx = graph.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph_nx, save_path) - - def save_graph_tool(graph, save_path): - graph_gt = graph.to_graphtool() - graph_gt.save(save_path, fmt=fmt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - locals()['save_' + backend](self, path) - - def set_signal(self, signal, name): - r""" - Add or modify a signal to the graph - - Parameters - ---------- - signal : numpy.array - An array mapping from node to his value. For example the value of the signal at node i is signal[i] - name : String - Name associated to the signal. - - Examples - -------- - >>> graph = graphs.Logo() - >>> DELTAS = [20, 30, 1090] - >>> signal = np.zeros(graph.N) - >>> signal[DELTAS] = 1 - >>> graph.set_signal(signal, 'diffusion') - - """ - if len(signal) != self.N: - raise ValueError("A value must be attached to every vertex in the graph") - self.signals[name] = np.asarray(signal) def check_weights(self): r"""Check the characteristics of the weights matrix. @@ -703,7 +443,7 @@ def is_directed(self, recompute=False): Returns ------- directed : bool - True if the graph is directed. + True if the graph is directed, False otherwise. Examples -------- @@ -735,6 +475,43 @@ def is_directed(self, recompute=False): self._directed = (self.W != self.W.T).nnz != 0 return self._directed + def has_loops(self): + r"""Check if any vertex is connected to itself. + + A graph has self-loops if and only if the diagonal entries of its + adjacency matrix are not all zero. + + Returns + ------- + loops : bool + True if the graph has self-loops, False otherwise. + + Examples + -------- + + Without self-loops: + + >>> graph = graphs.Graph([ + ... [0, 3, 0], + ... [3, 0, 4], + ... [0, 0, 0], + ... ]) + >>> graph.has_loops() + False + + With a self-loop: + + >>> graph = graphs.Graph([ + ... [1, 3, 0], + ... [3, 0, 4], + ... [0, 0, 0], + ... ]) + >>> graph.has_loops() + True + + """ + return np.any(self.W.diagonal() != 0) + def extract_components(self): r"""Split the graph into connected components. @@ -754,7 +531,7 @@ def extract_components(self): >>> from scipy import sparse >>> W = sparse.rand(10, 10, 0.2) >>> W = utils.symmetrize(W) - >>> G = graphs.Graph(W=W) + >>> G = graphs.Graph(W) >>> components = G.extract_components() >>> has_sinks = 'sink' in components[0].info >>> sinks_0 = components[0].info['sink'] if has_sinks else [] @@ -826,7 +603,7 @@ def compute_laplacian(self, lap_type='combinatorial'): Parameters ---------- lap_type : {'combinatorial', 'normalized'} - The type of Laplacian to compute. Default is combinatorial. + The kind of Laplacian to compute. Default is combinatorial. Examples -------- diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 8277b27f..178e89de 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -57,7 +57,7 @@ def __init__(self, N1=16, N2=None, **kwargs): plotting = {"limits": np.array([-1. / N2, 1 + 1. / N2, 1. / N1, 1 + 1. / N1])} - super(Grid2d, self).__init__(W=W, coords=coords, + super(Grid2d, self).__init__(W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index aee7adb2..8d2c47e2 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -30,5 +30,5 @@ def __init__(self, **kwargs): plotting = {"limits": np.array([0, 640, -400, 0])} - super(Logo, self).__init__(W=data['W'], coords=data['coords'], + super(Logo, self).__init__(data['W'], coords=data['coords'], plotting=plotting, **kwargs) diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index bb347ab9..a55de736 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -67,7 +67,7 @@ def __init__(self, k=6, **kwargs): "vertex_size": 75, "limits": np.array([0, 2**k + 1, 0, 2**k + 1])} - super(LowStretchTree, self).__init__(W=W, + super(LowStretchTree, self).__init__(W, coords=coords, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 55c23c23..6e429c8e 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -53,7 +53,7 @@ def __init__(self, connected=True, **kwargs): # Binarize: 8 entries are equal to 2 instead of 1. A = (A > 0).astype(bool) - super(Minnesota, self).__init__(W=A, coords=data['xy'], + super(Minnesota, self).__init__(A, coords=data['xy'], plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index d567748e..c619640b 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -35,7 +35,7 @@ def __init__(self, img, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): self.Gp = ImgPatches(img, **kwargs) W = aggregate(self.Gp.W, self.Gg.W) - super(Grid2dImgPatches, self).__init__(W=W, + super(Grid2dImgPatches, self).__init__(W, coords=self.Gg.coords, plotting=self.Gg.plotting) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 4ca8f9a2..24cdcf82 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -492,7 +492,12 @@ def __init__(self, features, standardize=False, self.radius = radius self.kernel_width = kernel_width +<<<<<<< HEAD super(NNGraph, self).__init__(W=W, coords=features, **params_graph) +======= + super(NNGraph, self).__init__(W, plotting=plotting, + coords=Xout, **kwargs) +>>>>>>> improve graph construction from adjacency def _get_extra_repr(self): attrs = { diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index 480baa12..5018b503 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -44,7 +44,7 @@ def __init__(self, N=16, directed=False, **kwargs): W = sparse.csr_matrix((weights, (sources, targets)), shape=(N, N)) plotting = {"limits": np.array([-1, N, -1, 1])} - super(Path, self).__init__(W=W, plotting=plotting, **kwargs) + super(Path, self).__init__(W, plotting=plotting, **kwargs) self.set_coordinates('line2D') diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index b3c85ea5..49c151bd 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -100,7 +100,7 @@ def __init__(self, N=64, k=6, max_iter=10, seed=None, **kwargs): v = sorted([i1, i2]) U = np.concatenate((U[:v[0]], U[v[0] + 1:v[1]], U[v[1] + 1:])) - super(RandomRegular, self).__init__(W=A, **kwargs) + super(RandomRegular, self).__init__(A, **kwargs) self.is_regular() diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index a28a11c0..1c24a1d5 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -74,7 +74,7 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): plotting = {'limits': np.array([-1, 1, -1, 1])} # TODO: save angle and 2D position as graph signals - super(RandomRing, self).__init__(W=W, coords=coords, plotting=plotting, + super(RandomRing, self).__init__(W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 4935b217..af57f3be 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -61,7 +61,7 @@ def __init__(self, N=64, k=1, **kwargs): plotting = {'limits': np.array([-1, 1, -1, 1])} - super(Ring, self).__init__(W=W, plotting=plotting, **kwargs) + super(Ring, self).__init__(W, plotting=plotting, **kwargs) self.set_coordinates('ring2D') diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 6951e36d..409534cb 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -147,7 +147,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} - super(StochasticBlockModel, self).__init__(W=W, **kwargs) + super(StochasticBlockModel, self).__init__(W, **kwargs) def _get_extra_repr(self): attrs = {'k': self.k} diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index a43d3138..7dc4404c 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -93,7 +93,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, 'distance': 7, } - super(SwissRoll, self).__init__(W=W, coords=coords.T, + super(SwissRoll, self).__init__(W, coords=coords.T, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 20520180..f28a8141 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -92,7 +92,7 @@ def __init__(self, Nv=16, Mv=None, **kwargs): 'limits': np.array([-2.5, 2.5, -2.5, 2.5, -2.5, 2.5]) } - super(Torus, self).__init__(W=W, coords=coords, + super(Torus, self).__init__(W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 7e5bd73b..14d6020e 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -122,7 +122,7 @@ def graph_sparsify(M, epsilon, maxiter=10): sparserW = sparserW + sparserW.T sparserL = sparse.diags(sparserW.diagonal(), 0) - sparserW - if graphs.Graph(W=sparserW).is_connected(): + if graphs.Graph(sparserW).is_connected(): break elif i == maxiter - 1: logger.warning('Despite attempts to reduce epsilon, sparsified graph is disconnected') @@ -134,7 +134,7 @@ def graph_sparsify(M, epsilon, maxiter=10): if not M.is_directed(): sparserW = (sparserW + sparserW.T) / 2. - Mnew = graphs.Graph(W=sparserW) + Mnew = graphs.Graph(sparserW) #M.copy_graph_attributes(Mnew) else: Mnew = sparse.lil_matrix(sparserL) @@ -360,7 +360,7 @@ def kron_reduction(G, ind): Wnew = Wnew - Wnew.diagonal() coords = G.coords[ind, :] if len(G.coords.shape) else np.ndarray(None) - Gnew = graphs.Graph(W=Wnew, coords=coords, lap_type=G.lap_type, + Gnew = graphs.Graph(Wnew, coords=coords, lap_type=G.lap_type, plotting=G.plotting) else: Gnew = Lnew diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 3afb269e..9b5ed893 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -39,17 +39,46 @@ def tearDownClass(cls): pass def test_graph(self): - W = np.arange(16).reshape(4, 4) - G = graphs.Graph(W) - np.testing.assert_allclose(G.W.A, W) - np.testing.assert_allclose(G.A.A, G.W.A > 0) - self.assertEqual(G.N, 4) - np.testing.assert_allclose(G.d, np.array([3, 4, 4, 4])) - self.assertEqual(G.Ne, 15) - self.assertTrue(G.is_directed()) - ki, kj = np.nonzero(G.A) - self.assertEqual(ki.shape[0], G.Ne) - self.assertEqual(kj.shape[0], G.Ne) + adjacency = [ + [0., 3., 0., 2.], + [3., 0., 4., 0.], + [0., 4., 0., 5.], + [2., 0., 5., 0.], + ] + + # Input types. + G = graphs.Graph(adjacency) + self.assertIs(type(G.W), sparse.csr_matrix) + adjacency = np.array(adjacency) + G = graphs.Graph(adjacency) + self.assertIs(type(G.W), sparse.csr_matrix) + adjacency = sparse.coo_matrix(adjacency) + G = graphs.Graph(adjacency) + self.assertIs(type(G.W), sparse.csr_matrix) + adjacency = sparse.csr_matrix(adjacency) + # G = graphs.Graph(adjacency) + # self.assertIs(G.W, adjacency) # Not copied if already CSR. + + # Attributes. + np.testing.assert_allclose(G.W.toarray(), adjacency.toarray()) + np.testing.assert_allclose(G.A.toarray(), G.W.toarray() > 0) + np.testing.assert_allclose(G.d, np.array([2, 2, 2, 2])) + np.testing.assert_allclose(G.dw, np.array([5, 7, 9, 7])) + self.assertEqual(G.n_vertices, 4) + self.assertIs(G.N, G.n_vertices) + self.assertEqual(G.n_edges, 4) + self.assertIs(G.Ne, G.n_edges) + + # Errors and warnings. + self.assertRaises(ValueError, graphs.Graph, np.ones((3, 4))) + self.assertRaises(ValueError, graphs.Graph, np.ones((3, 3, 4))) + self.assertRaises(ValueError, graphs.Graph, [[0, np.nan], [0, 0]]) + self.assertRaises(ValueError, graphs.Graph, [[0, np.inf], [0, 0]]) + if sys.version_info > (3, 4): # no assertLogs in python 2.7 + with self.assertLogs(level='WARNING'): + graphs.Graph([[0, -1], [-1, 0]]) + with self.assertLogs(level='WARNING'): + graphs.Graph([[1, 1], [1, 0]]) def test_degree(self): W = 0.3 * (np.ones((4, 4)) - np.diag(4 * [1])) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index cbd824b1..af23f4b8 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -32,103 +32,5 @@ def test_symmetrize(self): np.testing.assert_equal(W1.toarray(), W2) self.assertRaises(ValueError, utils.symmetrize, W, 'sum') - def test_utils(self): - # Data init - W1 = np.arange(16).reshape((4, 4)) - mask1 = np.array([[1, 0, 1, 0], [0, 1, 0, 1], - [1, 0, 1, 0], [0, 1, 0, 1]]) - W1[mask1 == 1] = 0 - W1 = sparse.lil_matrix(W1) - G1 = graphs.Graph(W1) - lap1 = np.array([[10, -2.5, 0, -7.5], - [-2.5, 10, -7.5, 0], - [0, -7.5, 20, -12.5], - [-7.5, 0, -12.5, 20]]) - - sym1 = np.matrix([[0, 2.5, 0, 7.5], - [2.5, 0, 7.5, 0], - [0, 7.5, 0, 12.5], - [7.5, 0, 12.5, 0]]) - sym1 = sparse.lil_matrix(sym1) - weight_check1 = {'has_inf_val': False, 'has_nan_value': False, - 'is_not_square': False, 'diag_is_not_zero': False} - rep1 = {'lap': lap1, 'is_dir': True, 'weight_check': weight_check1, - 'is_conn': True, 'sym': sym1, 'lmax': 35.} - t1 = {'G': G1, 'rep': rep1} - - W2 = np.zeros((4, 4)) - W2[0, 1] = float('NaN') - W2[0, 2] = float('Inf') - G2 = graphs.Graph(W2) - weight_check2 = {'has_inf_val': True, 'has_nan_value': True, - 'is_not_square': True, 'diag_is_not_zero': False} - rep2 = {'lap': None, 'is_dir': True, 'weight_check': weight_check2, - 'is_conn': False} - t2 = {'G': G2, 'rep': rep2} - - W3 = np.zeros((4, 4)) - G3 = graphs.Graph(W3) - lap3 = W3 - sym3 = G3.W - weight_check3 = {'has_inf_val': False, 'has_nan_value': False, - 'is_not_square': False, 'diag_is_not_zero': False} - rep3 = {'lap': lap3, 'is_dir': False, 'weight_check': weight_check3, - 'is_conn': False, 'sym': sym3, 'lmax': 0.} - t3 = {'G': G3, 'rep': rep3} - - W4 = np.zeros((4, 4)) - np.fill_diagonal(W4, 1) - G4 = graphs.Graph(W4) - lap4 = np.zeros((4, 4)) - sym4 = sparse.csc_matrix(W4) - weight_check4 = {'has_inf_val': False, 'has_nan_value': False, - 'is_not_square': False, 'diag_is_not_zero': True} - rep4 = {'lap': lap4, 'is_dir': False, 'weight_check': weight_check4, - 'is_conn': False, 'sym': sym4, 'lmax': 0.} - t4 = {'G': G4, 'rep': rep4} - - test_graphs = [t1, t3, t4] - - def test_is_directed(G, is_dir): - self.assertEqual(G.is_directed(), is_dir) - - def test_laplacian(G, lap): - self.assertTrue((G.L == lap).all()) - - def test_estimate_lmax(G, lmax): - G.estimate_lmax() - self.assertTrue(lmax <= G.lmax and G.lmax <= 1.02 * lmax) - - def test_check_weights(G, w_c): - self.assertEqual(G.check_weights(), w_c) - - def test_is_connected(G, is_conn, **kwargs): - self.assertEqual(G.is_connected(), is_conn) - - def test_distanz(x, y): - # TODO test with matlab to compare - self.assertEqual(utils.distanz(x, y)) - - # Not ready yet - # def test_tree_depths(A, root): - # # mat_answser = None - # self.assertEqual(mat_answser, utils.tree_depths(A, root)) - for t in test_graphs: - test_is_directed(t['G'], t['rep']['is_dir']) - test_laplacian(t['G'], t['rep']['lap']) - test_estimate_lmax(t['G'], t['rep']['lmax']) - test_check_weights(t['G'], t['rep']['weight_check']) - test_is_connected(t['G'], t['rep']['is_conn']) - - G5 = graphs.Graph(np.arange(16).reshape((4, 4))) - checks5 = {'has_inf_val': False, 'has_nan_value': False, - 'is_not_square': False, 'diag_is_not_zero': True} - test_check_weights(G5, checks5) - - # Not ready yet - # test_tree_depths(A, root) - - # test_distanz(x, y) - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 1a032d45ef698edb11604568fe1dd98f2335af69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:08:45 +0100 Subject: [PATCH 163/365] fix bug in weighted degree (had no impact) --- pygsp/graphs/graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 94467d1b..c2769979 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -800,7 +800,8 @@ def dw(self): """ if not hasattr(self, '_dw'): - if not self.is_directed: + if not self.is_directed(): + # Shortcut for undirected graphs. self._dw = np.ravel(self.W.sum(axis=0)) else: degree_in = np.ravel(self.W.sum(axis=0)) From 083bb61c55afb575394445c5631ac002ed139d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:41:06 +0100 Subject: [PATCH 164/365] improve degree * non-weigthed degree for directed graphs * documentation for non-weigthed degree * better examples * better tests --- pygsp/graphs/graph.py | 89 +++++++++++++++++++++++++++++++++----- pygsp/tests/test_graphs.py | 22 +++++++--- 2 files changed, 94 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index c2769979..e8d88bb3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -import warnings -from itertools import groupby +from __future__ import division + from collections import Counter import numpy as np @@ -72,7 +72,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): [2., 0., 5.], [0., 5., 0.]]) >>> graph.d - array([1, 2, 1]) + array([1, 2, 1], dtype=int32) >>> graph.dw array([2., 7., 5.]) >>> graph.L.toarray() @@ -767,14 +767,61 @@ def A(self): @property def d(self): - r"""The degree (the number of neighbors) of each node.""" + r"""The degree (number of neighbors) of vertices. + + For undirected graphs, the degree of a vertex is the number of vertices + it is connected to. + For directed graphs, the degree is the average of the in and out + degrees, where the in degree is the number of incoming edges, and the + out degree the number of outgoing edges. + + In both cases, the degree of the vertex :math:`v_i` is the average + between the number of non-zero values in the :math:`i`-th column (the + in degree) and the :math:`i`-th row (the out degree) of the weighted + adjacency matrix :attr:`W`. + + Examples + -------- + + Undirected graph: + + >>> graph = graphs.Graph([ + ... [0, 1, 0], + ... [1, 0, 2], + ... [0, 2, 0], + ... ]) + >>> print(graph.d) # Number of neighbors. + [1 2 1] + >>> print(graph.dw) # Weighted degree. + [1 3 2] + + Directed graph: + + >>> graph = graphs.Graph([ + ... [0, 1, 0], + ... [0, 0, 2], + ... [0, 2, 0], + ... ]) + >>> print(graph.d) # Number of neighbors. + [0.5 1.5 1. ] + >>> print(graph.dw) # Weighted degree. + [0.5 2.5 2. ] + + """ if not hasattr(self, '_d'): - self._d = np.asarray(self.A.sum(axis=1)).squeeze() + if not self.is_directed(): + # Shortcut for undirected graphs. + self._d = self.W.getnnz(axis=1) + # axis=1 faster for CSR (https://stackoverflow.com/a/16391764) + else: + degree_in = self.W.getnnz(axis=0) + degree_out = self.W.getnnz(axis=1) + self._d = (degree_in + degree_out) / 2 return self._d @property def dw(self): - r"""The weighted degree of nodes. + r"""The weighted degree of vertices. For undirected graphs, the weighted degree of the vertex :math:`v_i` is defined as @@ -793,10 +840,30 @@ def dw(self): Examples -------- - >>> graphs.Path(4, directed=False).dw - array([1., 2., 2., 1.]) - >>> graphs.Path(4, directed=True).dw - array([0.5, 1. , 1. , 0.5]) + + Undirected graph: + + >>> graph = graphs.Graph([ + ... [0, 1, 0], + ... [1, 0, 2], + ... [0, 2, 0], + ... ]) + >>> print(graph.d) # Number of neighbors. + [1 2 1] + >>> print(graph.dw) # Weighted degree. + [1 3 2] + + Directed graph: + + >>> graph = graphs.Graph([ + ... [0, 1, 0], + ... [0, 0, 2], + ... [0, 2, 0], + ... ]) + >>> print(graph.d) # Number of neighbors. + [0.5 1.5 1. ] + >>> print(graph.dw) # Weighted degree. + [0.5 2.5 2. ] """ if not hasattr(self, '_dw'): @@ -806,7 +873,7 @@ def dw(self): else: degree_in = np.ravel(self.W.sum(axis=0)) degree_out = np.ravel(self.W.sum(axis=1)) - self._dw = 0.5 * (degree_in + degree_out) + self._dw = (degree_in + degree_out) / 2 return self._dw @property diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 9b5ed893..48d13104 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -81,12 +81,22 @@ def test_graph(self): graphs.Graph([[1, 1], [1, 0]]) def test_degree(self): - W = 0.3 * (np.ones((4, 4)) - np.diag(4 * [1])) - G = graphs.Graph(W) - A = np.ones(W.shape) - np.diag(np.ones(4)) - np.testing.assert_allclose(G.A.toarray(), A) - np.testing.assert_allclose(G.d, 3 * np.ones([4])) - np.testing.assert_allclose(G.dw, 3 * 0.3) + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 2], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), False) + np.testing.assert_allclose(graph.d, [1, 2, 1]) + np.testing.assert_allclose(graph.dw, [1, 3, 2]) + graph = graphs.Graph([ + [0, 1, 0], + [0, 0, 2], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), True) + np.testing.assert_allclose(graph.d, [0.5, 1.5, 1]) + np.testing.assert_allclose(graph.dw, [0.5, 2.5, 2]) def test_is_connected(self): graph = graphs.Graph([ From 5654ab7d1c4c48d0ace33529975257791c9c72d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:51:11 +0100 Subject: [PATCH 165/365] changing the Laplacian invalidates Fourier and difference --- pygsp/filters/filter.py | 2 +- pygsp/graphs/difference.py | 4 ++-- pygsp/graphs/fourier.py | 12 ++++++------ pygsp/graphs/graph.py | 36 +++++++++++++++++++++++++++++------- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 1fcbcf00..6087bdbf 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -637,7 +637,7 @@ def complement(self, frame_bound=None): >>> g += g.complement() >>> A, B = g.estimate_frame_bounds() >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.971, B=1.971 + A=1.972, B=1.972 >>> fig, ax = g.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index df2e3d92..879aeab3 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -19,7 +19,7 @@ def D(self): Is computed by :func:`compute_differential_operator`. """ - if not hasattr(self, '_D'): + if self._D is None: self.logger.warning('The differential operator G.D is not ' 'available, we need to compute it. Explicitly ' 'call G.compute_differential_operator() ' @@ -166,7 +166,7 @@ def compute_differential_operator(self): self._D = sparse.csc_matrix((values, (rows, columns)), shape=(self.n_vertices, self.n_edges)) - self.D.eliminate_zeros() # Self-loops introduce stored zeros. + self._D.eliminate_zeros() # Self-loops introduce stored zeros. def grad(self, x): r"""Compute the gradient of a signal defined on the vertices. diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 9091b97d..379e2336 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -12,7 +12,7 @@ class GraphFourier(object): def _check_fourier_properties(self, name, desc): - if not hasattr(self, '_' + name): + if getattr(self, '_' + name) is None: self.logger.warning('The {} G.{} is not available, we need to ' 'compute the Fourier basis. Explicitly call ' 'G.compute_fourier_basis() once beforehand ' @@ -150,12 +150,12 @@ def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): if n_eigenvectors is None: n_eigenvectors = self.N - if (hasattr(self, '_e') and hasattr(self, '_U') and not recompute - and n_eigenvectors <= len(self.e)): + if (self._e is not None and self._U is not None and not recompute + and n_eigenvectors <= len(self._e)): return - assert self.L.shape == (self.N, self.N) - if self.N**2 * n_eigenvectors > 3000**3: + assert self.L.shape == (self.n_vertices, self.n_vertices) + if self.n_vertices**2 * n_eigenvectors > 3000**3: self.logger.warning( 'Computing the {0} eigendecomposition of a large matrix ({1} x' ' {1}) is expensive. Consider decreasing n_eigenvectors ' @@ -165,7 +165,7 @@ def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): self.N)) # TODO: handle non-symmetric Laplacians. Test lap_type? - if n_eigenvectors == self.N: + if n_eigenvectors == self.n_vertices: self._e, self._U = np.linalg.eigh(self.L.toarray()) else: # fast partial eigendecomposition of hermitian matrices diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e8d88bb3..e396a654 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -133,8 +133,6 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, off_diagonal = self.W.nnz - diagonal self.n_edges = off_diagonal // 2 + diagonal - self.compute_laplacian(lap_type) - if coords is not None: self.coords = np.asanyarray(coords) @@ -146,6 +144,21 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.plotting.update(plotting) self.signals = dict() + # Attributes that are lazily computed. + self._A = None + self._d = None + self._dw = None + self._lmax = None + self._U = None + self._e = None + self._coherence = None + self._D = None + # self._L = None + + # TODO: what about Laplacian? Lazy as Fourier, or disallow change? + self.lap_type = lap_type + self.compute_laplacian(lap_type) + # TODO: kept for backward compatibility. self.Ne = self.n_edges self.N = self.n_vertices @@ -667,6 +680,15 @@ def compute_laplacian(self, lap_type='combinatorial'): """ + if lap_type != self.lap_type: + # Those attributes are invalidated when the Laplacian is changed. + # Alternative: don't allow the user to change the Laplacian. + self._lmax = None + self._U = None + self._e = None + self._coherence = None + self._D = None + self.lap_type = lap_type if not self.is_directed(): @@ -761,7 +783,7 @@ def A(self): It is represented as an N-by-N matrix of booleans. :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. """ - if not hasattr(self, '_A'): + if self._A is None: self._A = self.W > 0 return self._A @@ -808,7 +830,7 @@ def d(self): [0.5 2.5 2. ] """ - if not hasattr(self, '_d'): + if self._d is None: if not self.is_directed(): # Shortcut for undirected graphs. self._d = self.W.getnnz(axis=1) @@ -866,7 +888,7 @@ def dw(self): [0.5 2.5 2. ] """ - if not hasattr(self, '_dw'): + if self._dw is None: if not self.is_directed(): # Shortcut for undirected graphs. self._dw = np.ravel(self.W.sum(axis=0)) @@ -883,7 +905,7 @@ def lmax(self): Can be exactly computed by :func:`compute_fourier_basis` or approximated by :func:`estimate_lmax`. """ - if not hasattr(self, '_lmax'): + if self._lmax is None: self.logger.warning('The largest eigenvalue G.lmax is not ' 'available, we need to estimate it. ' 'Explicitly call G.estimate_lmax() or ' @@ -939,7 +961,7 @@ def estimate_lmax(self, method='lanczos', recompute=False): 18.58 """ - if hasattr(self, '_lmax') and not recompute: + if self._lmax is not None and not recompute: return if method == 'lanczos': From 6cc776472455c2f1dd694b822484f790b0690c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 13:52:00 +0100 Subject: [PATCH 166/365] in-place modification of the graph is not allowed anymore --- pygsp/filters/filter.py | 2 +- pygsp/graphs/fourier.py | 10 ++-- pygsp/graphs/graph.py | 71 +++++++++++++++------------- pygsp/graphs/stochasticblockmodel.py | 4 +- pygsp/plotting.py | 2 +- pygsp/reduction.py | 4 +- pygsp/tests/test_graphs.py | 21 ++++---- pygsp/tests/test_plotting.py | 1 - 8 files changed, 59 insertions(+), 56 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 6087bdbf..1fcbcf00 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -637,7 +637,7 @@ def complement(self, frame_bound=None): >>> g += g.complement() >>> A, B = g.estimate_frame_bounds() >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.972, B=1.972 + A=1.971, B=1.971 >>> fig, ax = g.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 379e2336..0dbadc3f 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -89,7 +89,7 @@ def coherence(self): return self._check_fourier_properties('coherence', 'Fourier basis coherence') - def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): + def compute_fourier_basis(self, n_eigenvectors=None): r"""Compute the (partial) Fourier basis of the graph (cached). The result is cached and accessible by the :attr:`U`, :attr:`e`, @@ -100,8 +100,6 @@ def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): n_eigenvectors : int or `None` Number of eigenvectors to compute. If `None`, all eigenvectors are computed. (default: None) - recompute: bool - Force to recompute the Fourier basis if already existing. Notes ----- @@ -148,10 +146,9 @@ def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): """ if n_eigenvectors is None: - n_eigenvectors = self.N + n_eigenvectors = self.n_vertices - if (self._e is not None and self._U is not None and not recompute - and n_eigenvectors <= len(self._e)): + if (self._U is not None and n_eigenvectors <= len(self._e)): return assert self.L.shape == (self.n_vertices, self.n_vertices) @@ -186,6 +183,7 @@ def compute_fourier_basis(self, n_eigenvectors=None, recompute=False): assert np.max(self._e) == self._e[-1] if n_eigenvectors == self.N: self._lmax = self._e[-1] + self._lmax_method = 'fourier' self._coherence = np.max(np.abs(self._U)) def gft(self, s): diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e396a654..d547bdff 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -105,35 +105,39 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, # CSR sparse matrices are the most efficient for matrix multiplication. # They are the sole sparse matrix type to support eliminate_zeros(). - self.W = sparse.csr_matrix(adjacency, copy=False) + self._adjacency = sparse.csr_matrix(adjacency, copy=False) - if np.isnan(self.W.sum()): + if np.isnan(self._adjacency.sum()): raise ValueError('Adjacency: there is a Not a Number (NaN).') - if np.isinf(self.W.sum()): + if np.isinf(self._adjacency.sum()): raise ValueError('Adjacency: there is an infinite value.') if self.has_loops(): self.logger.warning('Adjacency: there are self-loops ' '(non-zeros on the diagonal). ' 'The Laplacian will not see them.') - if (self.W < 0).nnz != 0: + if (self._adjacency < 0).nnz != 0: self.logger.warning('Adjacency: there are negative edge weights.') - self.n_vertices = self.W.shape[0] + self.n_vertices = self._adjacency.shape[0] # Don't keep edges of 0 weight. Otherwise n_edges will not correspond # to the real number of edges. Problematic when plotting. - self.W.eliminate_zeros() + self._adjacency.eliminate_zeros() + + self._directed = None + self._connected = None # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. if self.is_directed(): - self.n_edges = self.W.nnz + self.n_edges = self._adjacency.nnz else: - diagonal = np.count_nonzero(self.W.diagonal()) - off_diagonal = self.W.nnz - diagonal + diagonal = np.count_nonzero(self._adjacency.diagonal()) + off_diagonal = self._adjacency.nnz - diagonal self.n_edges = off_diagonal // 2 + diagonal if coords is not None: + # TODO: self.coords should be None if unset. self.coords = np.asanyarray(coords) self.plotting = {'vertex_size': 100, @@ -149,6 +153,7 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self._d = None self._dw = None self._lmax = None + self._lmax_method = None self._U = None self._e = None self._coherence = None @@ -363,17 +368,12 @@ def subgraph(self, vertices): coords = None return Graph(adjacency, self.lap_type, coords, self.plotting) - def is_connected(self, recompute=False): + def is_connected(self): r"""Check if the graph is connected (cached). A graph is connected if and only if there exists a (directed) path between any two vertices. - Parameters - ---------- - recompute: bool - Force to recompute the connectivity if already known. - Returns ------- connected : bool @@ -414,11 +414,11 @@ def is_connected(self, recompute=False): """ - if hasattr(self, '_connected') and not recompute: + if self._connected is not None: return self._connected adjacencies = [self.W] - if self.is_directed(recompute=recompute): + if self.is_directed(): adjacencies.append(self.W.T) for adjacency in adjacencies: @@ -442,17 +442,12 @@ def is_connected(self, recompute=False): self._connected = True return self._connected - def is_directed(self, recompute=False): + def is_directed(self): r"""Check if the graph has directed edges (cached). In this framework, we consider that a graph is directed if and only if its weight matrix is not symmetric. - Parameters - ---------- - recompute : bool - Force to recompute the directedness if already known. - Returns ------- directed : bool @@ -482,10 +477,8 @@ def is_directed(self, recompute=False): False """ - if hasattr(self, '_directed') and not recompute: - return self._directed - - self._directed = (self.W != self.W.T).nnz != 0 + if self._directed is None: + self._directed = (self.W != self.W.T).nnz != 0 return self._directed def has_loops(self): @@ -674,7 +667,7 @@ def compute_laplacian(self, lap_type='combinatorial'): >>> -1e-10 < G.e[0] < 1e-10 < G.e[-1] < 2*np.max(G.dw) True >>> G.compute_laplacian('normalized') - >>> G.compute_fourier_basis(recompute=True) + >>> G.compute_fourier_basis() >>> -1e-10 < G.e[0] < 1e-10 < G.e[-1] < 2 True @@ -775,6 +768,17 @@ def dirichlet_energy(self, x): x = self._check_signal(x) return x.T.dot(self.L.dot(x)) + @property + def W(self): + r"""Weighted adjacency matrix of the graph.""" + return self._adjacency + + @W.setter + def W(self, value): + # TODO: user can still do G.W[0, 0] = 1, or modify the passed W. + raise AttributeError('In-place modification of the graph is not ' + 'supported. Create another Graph object.') + @property def A(self): r"""Graph adjacency matrix (the binary version of W). @@ -914,7 +918,7 @@ def lmax(self): self.estimate_lmax() return self._lmax - def estimate_lmax(self, method='lanczos', recompute=False): + def estimate_lmax(self, method='lanczos'): r"""Estimate the Laplacian's largest eigenvalue (cached). The result is cached and accessible by the :attr:`lmax` property. @@ -929,8 +933,6 @@ def estimate_lmax(self, method='lanczos', recompute=False): Whether to estimate the largest eigenvalue with the implicitly restarted Lanczos method, or to return an upper bound on the spectrum of the Laplacian. - recompute : boolean - Force to recompute the largest eigenvalue. Default is false. Notes ----- @@ -953,16 +955,17 @@ def estimate_lmax(self, method='lanczos', recompute=False): >>> G.compute_fourier_basis() # True value. >>> print('{:.2f}'.format(G.lmax)) 13.78 - >>> G.estimate_lmax(recompute=True) # Estimate. + >>> G.estimate_lmax(method='lanczos') # Estimate. >>> print('{:.2f}'.format(G.lmax)) 13.92 - >>> G.estimate_lmax(method='bounds', recompute=True) # Upper bound. + >>> G.estimate_lmax(method='bounds') # Upper bound. >>> print('{:.2f}'.format(G.lmax)) 18.58 """ - if self._lmax is not None and not recompute: + if method == self._lmax_method: return + self._lmax_method = method if method == 'lanczos': try: diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 409534cb..63918b72 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -133,9 +133,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break - self.W = W - self.n_vertices = W.shape[0] - if self.is_connected(recompute=True): + if Graph(W).is_connected(): break if n_try is not None: n_try -= 1 diff --git a/pygsp/plotting.py b/pygsp/plotting.py index cbc8fb9d..d887316b 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -237,7 +237,7 @@ def _plot_filter(filters, n, eigenvalues, sum, title, ax, **kwargs): """ if eigenvalues is None: - eigenvalues = hasattr(filters.G, '_e') + eigenvalues = (filters.G._e is not None) if sum is None: sum = filters.n_filters > 1 diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 14d6020e..50a8ae67 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -257,7 +257,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, for i in range(levels): if downsampling_method == 'largest_eigenvector': - if hasattr(Gs[i], '_U'): + if Gs[i]._U is not None: V = Gs[i].U[:, -1] else: V = linalg.eigs(Gs[i].L, 1)[1][:, 0] @@ -472,7 +472,7 @@ def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): """ least_squares = bool(kwargs.pop('least_squares', False)) - def_ul = Gs[0].N > 3000 or not hasattr(Gs[0], '_e') or not hasattr(Gs[0], '_U') + def_ul = Gs[0].N > 3000 or Gs[0]._e is None or Gs[0]._U is None use_landweber = bool(kwargs.pop('use_landweber', def_ul)) reg_eps = float(kwargs.get('reg_eps', 0.005)) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 48d13104..304e22c0 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -79,6 +79,10 @@ def test_graph(self): graphs.Graph([[0, -1], [-1, 0]]) with self.assertLogs(level='WARNING'): graphs.Graph([[1, 1], [1, 0]]) + for attr in ['A', 'd', 'dw', 'lmax', 'U', 'e', 'coherence', 'D']: + # FIXME: The Laplacian L should be there as well. + self.assertRaises(AttributeError, setattr, G, attr, None) + self.assertRaises(AttributeError, delattr, G, attr) def test_degree(self): graph = graphs.Graph([ @@ -137,12 +141,13 @@ def test_is_directed(self): ]) assert graph.W.nnz == 6 self.assertEqual(graph.is_directed(), False) - graph.W[0, 1] = 0 - assert graph.W.nnz == 6 - self.assertEqual(graph.is_directed(recompute=True), True) - graph.W[1, 0] = 0 - assert graph.W.nnz == 6 - self.assertEqual(graph.is_directed(recompute=True), False) + # In-place modification is not allowed anymore. + # graph.W[0, 1] = 0 + # assert graph.W.nnz == 6 + # self.assertEqual(graph.is_directed(recompute=True), True) + # graph.W[1, 0] = 0 + # assert graph.W.nnz == 6 + # self.assertEqual(graph.is_directed(recompute=True), False) def test_laplacian(self): @@ -206,9 +211,9 @@ def test_estimate_lmax(self): self.assertRaises(ValueError, graph.estimate_lmax, method='unk') def check_lmax(graph, lmax): - graph.estimate_lmax(method='bounds', recompute=True) + graph.estimate_lmax(method='bounds') np.testing.assert_allclose(graph.lmax, lmax) - graph.estimate_lmax(method='lanczos', recompute=True) + graph.estimate_lmax(method='lanczos') np.testing.assert_allclose(graph.lmax, lmax*1.01) graph.compute_fourier_basis() np.testing.assert_allclose(graph.lmax, lmax) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index a28ae0c3..2d07d028 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -71,7 +71,6 @@ def test_plot_graphs(self): for G in Gs: self.assertTrue(hasattr(G, 'coords')) - self.assertTrue(hasattr(G, 'A')) self.assertEqual(G.N, G.coords.shape[0]) signal = np.arange(G.N) + 0.3 From 79a8a421f0718247d249f64ff2f4542aa507b85f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Cordonnier Date: Tue, 5 Mar 2019 13:57:16 +0100 Subject: [PATCH 167/365] Add highlight node color and normalize intercept --- pygsp/graphs/graph.py | 4 +++- pygsp/plotting.py | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d547bdff..0f5f4aa2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -144,7 +144,9 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, 'vertex_color': (0.12, 0.47, 0.71, 0.5), 'edge_color': (0.5, 0.5, 0.5, 0.5), 'edge_width': 2, - 'edge_style': '-'} + 'edge_style': '-', + 'highlight_color': 'C1', + 'normalize_intercept': .25} self.plotting.update(plotting) self.signals = dict() diff --git a/pygsp/plotting.py b/pygsp/plotting.py index d887316b..12c78f5c 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -403,11 +403,17 @@ def check_shape(signal, name, length, many=False): raise ValueError(txt) def normalize(x): - """Scale values in [0.25, 1]. Return 0.5 if constant.""" + """Scale values in [intercept, 1]. Return 0.5 if constant. + + Set intercept value in G.plotting["normalize_intercept"] + with value in [0, 1], default is .25. + """ ptp = x.ptp() if ptp == 0: return np.full(x.shape, 0.5) - return 0.75 * (x - x.min()) / ptp + 0.25 + else: + intercept = G.plotting['normalize_intercept'] + return (1. - intercept) * (x - x.min()) / ptp + intercept def is_color(color): @@ -526,7 +532,8 @@ def _plt_plot_graph(G, vertex_color, vertex_size, highlight, ax.plot(G.coords, vertex_color, alpha=0.5) ax.set_ylim(limits) for coord_hl in coords_hl: - ax.axvline(x=coord_hl, color='C1', linewidth=2) + ax.axvline(x=coord_hl, color=G.plotting['highlight_color'], + linewidth=2) else: sc = ax.scatter(*G.coords.T, @@ -539,7 +546,8 @@ def _plt_plot_graph(G, vertex_color, vertex_size, highlight, size_hl = vertex_size[highlight] ax.scatter(*coords_hl.T, s=2*size_hl, zorder=3, - marker='o', c='None', edgecolors='C1', linewidths=2) + marker='o', c='None', + edgecolors=G.plotting['highlight_color'], linewidths=2) if G.coords.shape[1] == 3: try: From 2e04059aa9eb41c2db5d998a73af2a93cc9a7fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 10 Mar 2019 11:13:22 +0100 Subject: [PATCH 168/365] import learning module in package --- pygsp/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index c75afd69..5a51c679 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -25,6 +25,7 @@ 'plotting', 'reduction', 'features', + 'learning', 'optimization', 'utils', ] From 041ab7f5243364603cb679f4c94cc57647fea0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 10 Mar 2019 11:19:57 +0100 Subject: [PATCH 169/365] doc: some updates --- pygsp/filters/filter.py | 6 +++--- pygsp/graphs/difference.py | 18 ++++++++++++++++++ pygsp/graphs/graph.py | 5 +++++ pygsp/plotting.py | 6 +++--- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 1fcbcf00..5e4901c3 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -406,16 +406,16 @@ def estimate_frame_bounds(self, x=None): As :math:`g(L) = U g(\Lambda) U^\top` is diagonalized by the Fourier basis :math:`U` with eigenvalues :math:`\Lambda`, :math:`\| g(L) x \|^2 - = \| g(\Lambda) U x \|^2`, and :math:`A = \min g^2(\Lambda)`, + = \| g(\Lambda) U^\top x \|^2`, and :math:`A = \min g^2(\Lambda)`, :math:`B = \max g^2(\Lambda)`. Parameters ---------- x : array_like Graph frequencies at which to evaluate the filter bank `g(x)`. - The default is `x = np.linspace(0, G.lmax, 1000)`. + The default is ``x = np.linspace(0, G.lmax, 1000)``. The exact bounds are given by evaluating the filter bank at the - eigenvalues of the graph Laplacian, i.e., `x = G.e`. + eigenvalues of the graph Laplacian, i.e., ``x = G.e``. Returns ------- diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 879aeab3..9ce6013b 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -213,21 +213,30 @@ def grad(self, x): Examples -------- + + Non-directed graph and combinatorial Laplacian: + >>> graph = graphs.Path(4, directed=False, lap_type='combinatorial') >>> graph.compute_differential_operator() >>> graph.grad([0, 2, 4, 2]) array([ 2., 2., -2.]) + Directed graph and combinatorial Laplacian: + >>> graph = graphs.Path(4, directed=True, lap_type='combinatorial') >>> graph.compute_differential_operator() >>> graph.grad([0, 2, 4, 2]) array([ 1.41421356, 1.41421356, -1.41421356]) + Non-directed graph and normalized Laplacian: + >>> graph = graphs.Path(4, directed=False, lap_type='normalized') >>> graph.compute_differential_operator() >>> graph.grad([0, 2, 4, 2]) array([ 1.41421356, 1.41421356, -0.82842712]) + Directed graph and normalized Laplacian: + >>> graph = graphs.Path(4, directed=True, lap_type='normalized') >>> graph.compute_differential_operator() >>> graph.grad([0, 2, 4, 2]) @@ -286,21 +295,30 @@ def div(self, y): Examples -------- + + Non-directed graph and combinatorial Laplacian: + >>> graph = graphs.Path(4, directed=False, lap_type='combinatorial') >>> graph.compute_differential_operator() >>> graph.div([2, -2, 0]) array([-2., 4., -2., 0.]) + Directed graph and combinatorial Laplacian: + >>> graph = graphs.Path(4, directed=True, lap_type='combinatorial') >>> graph.compute_differential_operator() >>> graph.div([2, -2, 0]) array([-1.41421356, 2.82842712, -1.41421356, 0. ]) + Non-directed graph and normalized Laplacian: + >>> graph = graphs.Path(4, directed=False, lap_type='normalized') >>> graph.compute_differential_operator() >>> graph.div([2, -2, 0]) array([-2. , 2.82842712, -1.41421356, 0. ]) + Directed graph and normalized Laplacian: + >>> graph = graphs.Path(4, directed=True, lap_type='normalized') >>> graph.compute_differential_operator() >>> graph.div([2, -2, 0]) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0f5f4aa2..27de1414 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -748,6 +748,9 @@ def dirichlet_energy(self, x): Examples -------- + + Non-directed graph: + >>> graph = graphs.Path(5, directed=False) >>> signal = [0, 2, 2, 4, 4] >>> graph.dirichlet_energy(signal) @@ -757,6 +760,8 @@ def dirichlet_energy(self, x): >>> graph.grad(signal) array([2., 0., 2., 0.]) + Directed graph: + >>> graph = graphs.Path(5, directed=True) >>> signal = [0, 2, 2, 4, 4] >>> graph.dirichlet_energy(signal) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 12c78f5c..289adcea 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -15,7 +15,7 @@ .. data:: BACKEND - Indicates which drawing backend to use if none are provided to the plotting + The default drawing backend to use if none are provided to the plotting functions. Should be either ``'matplotlib'`` or ``'pyqtgraph'``. In general pyqtgraph is better for interactive exploration while matplotlib is better at generating figures to be included in papers or elsewhere. @@ -128,7 +128,7 @@ def close_all(): def show(*args, **kwargs): - r"""Show created figures, alias to plt.show(). + r"""Show created figures, alias to ``plt.show()``. By default, showing plots does not block the prompt. Calling this function will block execution. @@ -138,7 +138,7 @@ def show(*args, **kwargs): def close(*args, **kwargs): - r"""Close last created figure, alias to plt.close().""" + r"""Close last created figure, alias to ``plt.close()``.""" _, plt, _ = _import_plt() plt.close(*args, **kwargs) From 6bcd3c1bc44267bd925a30a6e17bf40a72e6f4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 12 Mar 2019 13:02:29 +0100 Subject: [PATCH 170/365] doc: frame operator of the pseudo-inverse --- pygsp/filters/filter.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 5e4901c3..56d371bd 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -517,7 +517,7 @@ def compute_frame(self, **kwargs): *analysis operator* .. math:: - g(L) = \begin{pmatrix} g_0(L) \\ \vdots \\ g_F(L) \end{pmatrix} + g(L) = \begin{pmatrix} g_1(L) \\ \vdots \\ g_F(L) \end{pmatrix} \in \mathbb{R}^{NF \times N}, \quad g_i(L) = U g_i(\Lambda) U^\top, @@ -663,14 +663,21 @@ def kernel(x, *args, **kwargs): def inverse(self): r"""Return the pseudo-inverse filter bank. - The pseudo-inverse of the filter bank :math:`g` is the filter bank - :math:`g^+` such that + The pseudo-inverse of the *analysis filter bank* :math:`g` is the + *synthesis filter bank* :math:`g^+` such that .. math:: g(L)^+ g(L) = I, - where :math:`I` is the identity matrix, and :math:`g(L)^+ = (g(L)\top - g(L))^{-1} g(L)^\top` is the left pseudo-inverse of the analysis - operator :math:`g(L)`. + where :math:`I` is the identity matrix, and the *synthesis operator* + + .. math:: g(L)^+ = (g(L)\top g(L))^{-1} g(L)^\top + = (g_1(L)^+, \dots, g_F(L)^+) + \in \mathbb{R}^{N \times NF} + + is the left pseudo-inverse of the analysis operator :math:`g(L)`. Note + that :math:`g_i(L)^+` is the pseudo-inverse of :math:`g_i(L)`, + :math:`N` is the number of vertices, and :math:`F` is the number of + filters in the bank. The above relation holds, and the reconstruction is exact, if and only if :math:`g(L)` is a frame. To be a frame, the rows of :math:`g(L)` @@ -680,17 +687,22 @@ def inverse(self): will be the closest to :math:`x` in the least square sense. While there exists infinitely many inverses of the analysis operator of - a frame, the pseudo-inverse is unique and corresponds to the canonical - dual of the filter kernel. + a frame, the pseudo-inverse is unique and corresponds to the *canonical + dual* of the filter kernel. + + The *frame operator* of :math:`g^+` is :math:`g(L)^+ (g(L)^+)^\top = + (g(L)\top g(L))^{-1}`, the inverse of the frame operator of :math:`g`. + Similarly, its *frame bounds* are :math:`A^{-1}` and :math:`B^{-1}`, + where :math:`A` and :math:`B` are the frame bounds of :math:`g`. - If the frame is tight (i.e., :math:`A=B`), the canonical dual filters - are given by :math:`h_i = g_i / A`, where :math:`g_i` are the filters - composing the filter bank :math:`g`. + If :math:`g` is tight (i.e., :math:`A=B`), the canonical dual is given + by :math:`g^+ = g / A` (i.e., :math:`g^+_i = g_i / A \ \forall i`). Returns ------- - inverse: Filter - The pseudo-inverse filter bank. + inverse : :class:`pygsp.filters.Filter` + The pseudo-inverse filter bank, which synthesizes (or reconstructs) + a signal from its coefficients using the canonical dual frame. See also -------- From 9809dbdea1f71e1234500ae92593599c503e94da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 00:45:03 +0100 Subject: [PATCH 171/365] doc: update Fourier coherence --- pygsp/graphs/fourier.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 0dbadc3f..52bb87c9 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -40,19 +40,26 @@ def e(self): def coherence(self): r"""Coherence of the Fourier basis. - The mutual coherence between the basis of Kronecker deltas on the graph - and the basis of graph Laplacian eigenvectors is defined as + The *mutual coherence* between the basis of Kronecker deltas and the + basis formed by the eigenvectors of the graph Laplacian is defined as - .. math:: \mu = \max_{\ell,i} | \langle U_\ell, \delta_i \rangle | + .. math:: \mu = \max_{\ell,i} \langle U_\ell, \delta_i \rangle = \max_{\ell,i} | U_{\ell, i} | - \in \left[ \frac{1}{\sqrt{N}}, 1 \right]. + \in \left[ \frac{1}{\sqrt{N}}, 1 \right], - It is a measure of the localization of the Fourier modes (Laplacian - eigenvectors). The smaller the value, the more localized the + where :math:`N` is the number of vertices, :math:`\delta_i \in + \mathbb{R}^N` denotes the Kronecker delta that is non-zero on vertex + :math:`v_i`, and :math:`U_\ell \in \mathbb{R}^N` denotes the + :math:`\ell^\text{th}` eigenvector of the graph Laplacian (i.e., the + :math:`\ell^\text{th}` Fourier mode). + + The coherence is a measure of the localization of the Fourier modes + (Laplacian eigenvectors). The larger the value, the more localized the eigenvectors can be. The extreme is a node that is disconnected from the rest of the graph: an eigenvector will be localized as a Kronecker - delta there. In the classical setting, Fourier modes (which are complex - exponentials) are completely delocalized, and the coherence equals one. + delta there (i.e., :math:`\mu = 1`). In the classical setting, Fourier + modes (which are complex exponentials) are completely delocalized, and + the coherence is minimal. The value is computed by :meth:`compute_fourier_basis`. @@ -74,7 +81,7 @@ def coherence(self): Localized eigenvectors. - >>> graph = graphs.Sensor(64, seed=20) + >>> graph = graphs.Sensor(64, seed=10) >>> graph.compute_fourier_basis() >>> minimum = 1 / np.sqrt(graph.n_vertices) >>> print('{:.2f} in [{:.2f}, 1]'.format(graph.coherence, minimum)) @@ -82,8 +89,9 @@ def coherence(self): >>> >>> # Plot the most localized eigenvector. >>> import matplotlib.pyplot as plt - >>> idx = np.argmax(np.max(graph.U, axis=0)) - >>> _ = graph.plot(graph.U[:, idx]) + >>> idx = np.argmax(np.abs(graph.U)) + >>> idx_vertex, idx_fourier = np.unravel_index(idx, graph.U.shape) + >>> _ = graph.plot(graph.U[:, idx_fourier], highlight=idx_vertex) """ return self._check_fourier_properties('coherence', From 4ca7e9a8a7524dff99ccf79cdf315e0fd2de181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 00:53:42 +0100 Subject: [PATCH 172/365] doc: update modulation --- pygsp/filters/modulation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/modulation.py b/pygsp/filters/modulation.py index 9483648f..60833850 100644 --- a/pygsp/filters/modulation.py +++ b/pygsp/filters/modulation.py @@ -55,9 +55,9 @@ class Modulation(Filter): modulation_first : bool First modulate then localize the kernel if True, first localize then modulate if False. The two operators do not commute. This setting only - applies to `filter`, `evaluate` only performs modulation (the filter - would otherwise have a different spectrum depending on where it is - localized). + applies to :meth:`filter`. :meth:`evaluate` only performs modulation, + as the filter would otherwise have a different spectrum depending on + where it is localized. See Also -------- From f68a0c7b934f9d10380d000c0d3a58c1508c8e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 14:55:49 +0100 Subject: [PATCH 173/365] simplify travis.yml --- .travis.yml | 8 ++++++++ setup.py | 1 + 2 files changed, 9 insertions(+) diff --git a/.travis.yml b/.travis.yml index c93253f7..7c5f0439 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,6 +26,14 @@ install: - pip install --upgrade --upgrade-strategy eager .[dev] # Upgrade to test with the latest version of our dependencies. +before_script: + # As graph-tool cannot be installed by pip, link to the system installation + # from the virtual environment. + # TODO: remove the condition once we drop python 2.7. + - if [[ $(python -c 'import sys; print(sys.version_info[0])') = 3 ]] ; + then ln -s "/usr/lib/python3/dist-packages/graph_tool" $(python -c "import site; print(site.getsitepackages()[0])"); + else ln -s "/usr/lib/python2.7/dist-packages/graph_tool" $(python -c "import site; print(site.getsitepackages()[0])"); fi + script: # - make lint - make test diff --git a/setup.py b/setup.py index de0da4a8..c8b360de 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ 'dev': [ # Import and export. 'networkx', + # 'graph-tool', cannot be installed by pip # Construct patch graphs from images. 'scikit-image', # Approximate nearest neighbors for kNN graphs. From e7daee79176b06ee8d0fbad8cfa12692d150e5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 15:50:16 +0100 Subject: [PATCH 174/365] improve and test set_signal --- pygsp/graphs/graph.py | 30 +++++++++++++++++++++++++++++- pygsp/tests/test_graphs.py | 7 +++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 27de1414..52b7cea0 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -48,6 +48,8 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): The graph Laplacian, an N-by-N matrix computed from W. lap_type : 'normalized', 'combinatorial' The kind of Laplacian that was computed by :func:`compute_laplacian`. + signals : dict (string -> :class:`numpy.ndarray`) + Signals attached to the graph. coords : :class:`numpy.ndarray` Vertices coordinates in 2D or 3D space. Used for plotting only. plotting : dict @@ -240,6 +242,32 @@ def check_weights(self): 'is_not_square': is_not_square, 'diag_is_not_zero': diag_is_not_zero} + def set_signal(self, signal, name): + r"""Attach a signal to the graph. + + Attached signals can be accessed (and modified or deleted) through the + :attr:`signals` dictionary. + + Parameters + ---------- + signal : array_like + A sequence that assigns a value to each vertex. + The value of the signal at vertex `i` is ``signal[i]``. + name : String + Name of the signal used as a key in the :attr:`signals` dictionary. + + Examples + -------- + >>> graph = graphs.Sensor(10) + >>> signal = np.arange(graph.n_vertices) + >>> graph.set_signal(signal, 'mysignal') + >>> graph.signals + {'mysignal': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])} + + """ + signal = self._check_signal(signal) + self.signals[name] = signal + def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). @@ -708,7 +736,7 @@ def compute_laplacian(self, lap_type='combinatorial'): def _check_signal(self, s): r"""Check if signal is valid.""" s = np.asanyarray(s) - if s.shape[0] != self.N: + if s.shape[0] != self.n_vertices: raise ValueError('First dimension must be the number of vertices ' 'G.N = {}, got {}.'.format(self.N, s.shape)) return s diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 304e22c0..4a3f7439 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -423,6 +423,13 @@ def test(adjacency): test(sparse.csc_matrix(W)) test(sparse.coo_matrix(W)) + def test_set_signal(self, name='test'): + signal = np.zeros(self._G.n_vertices) + self._G.set_signal(signal, name) + self.assertIs(self._G.signals[name], signal) + signal = np.zeros(self._G.n_vertices // 2) + self.assertRaises(ValueError, self._G.set_signal, signal, name) + def test_set_coordinates(self): G = graphs.FullConnected() coords = self._rs.uniform(size=(G.N, 2)) From b903bca693ed919f01a66f7e5747328744d032cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:14:37 +0100 Subject: [PATCH 175/365] add new methods to doc --- pygsp/graphs/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 852259d3..08808401 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -87,12 +87,25 @@ Graph.plot Graph.plot_spectrogram +Import and export (I/O) +----------------------- + +.. autosummary:: + + Graph.load + Graph.save + Graph.from_networkx + Graph.to_networkx + Graph.from_graphtool + Graph.to_graphtool + Others ------ .. autosummary:: Graph.get_edge_list + Graph.set_signal Graph.set_coordinates Graph.subgraph Graph.extract_components From a563249e7323a35011ec9a51510e1c3d1c698c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:18:34 +0100 Subject: [PATCH 176/365] remove redundant :py directive --- pygsp/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 03e4cbdb..2ec9c495 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -374,7 +374,7 @@ def numpy2graph_tool_type(dtype): Parameters ---------- - dtype : :py:class:`numpy.dtype` + dtype : :class:`numpy.dtype` Returns ------- From 18ee14a5e36b7862ca60f99a54f7cc5a4f6f2a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:19:30 +0100 Subject: [PATCH 177/365] improve and test convert_dtype --- pygsp/tests/test_utils.py | 6 ++++++ pygsp/utils.py | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index af23f4b8..a7298319 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -32,5 +32,11 @@ def test_symmetrize(self): np.testing.assert_equal(W1.toarray(), W2) self.assertRaises(ValueError, utils.symmetrize, W, 'sum') + def test_convert_dtype(self): + signal = np.zeros(10, dtype=np.int16) + self.assertEqual(utils.convert_dtype(signal.dtype), 'int16_t') + signal = np.zeros(10, dtype=np.float128) + self.assertEqual(utils.convert_dtype(signal.dtype), None) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/utils.py b/pygsp/utils.py index 2ec9c495..70fbfcc9 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -365,25 +365,29 @@ def import_functions(names, src, dst): module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) -def numpy2graph_tool_type(dtype): + +def convert_dtype(dtype): r"""Convert from numpy dtype to graph tool types. - The supported numpy types are: {bool_, int_, int16, int32, int64, - float_, float16, float32, float64} - See graph_tool `doc `_ for more details. + The supported numpy types are: ``bool_``, ``int_``, ``int16``, ``int32``, + ``int64``, ``float_``, ``float16``, ``float32``, ``float64``. + + See the `graph-tool documentation + `_ + for details. Parameters ---------- dtype : :class:`numpy.dtype` + Numpy data type. Returns ------- - graph_tool_type : string - A string representing the type ready to be use by graph_tool + type : string + A string representing the type, ready to be used by graph-tool. """ - # Encode the numpy types with its correspondence in graph_tool - numpy2gt_type = { + translation = { np.bool_: 'bool', np.int_: 'int', np.int16: 'int16_t', @@ -396,8 +400,8 @@ def numpy2graph_tool_type(dtype): } try: - graph_tool_type = numpy2gt_type[dtype.type] - except: + graph_tool_type = translation[dtype.type] + except KeyError: graph_tool_type = None return graph_tool_type From ca43e654e628daa69553c5424b549fba83d227c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 19:48:57 +0100 Subject: [PATCH 178/365] tests: cleaner skipping --- pygsp/tests/test_graphs.py | 73 ++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4a3f7439..9106bde1 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -689,19 +689,17 @@ def test_networkx_import_export(self): np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_export_import(self): # Export to graph tool and reimport to PyGSP directly # The exported graph is a simple one without an associated Signal - if sys.version_info < (3, 0): - return None # skip test for python 2.7 g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_multiedge_import(self): - if sys.version_info < (3, 0): - return None # skip test for python2.7 # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) @@ -722,11 +720,10 @@ def test_graphtool_multiedge_import(self): g3 = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g3.W[3, 6], 9.0) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - if sys.version_info < (3, 0): - return None # skip test for python2.7 graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) eprop_double = graph_gt.new_edge_property("double") @@ -772,9 +769,8 @@ def test_graphtool_signal_export(self): self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_signal_import(self): - if sys.version_info < (3, 0): - return None # skip test for python2.7 g_gt = gt.Graph() g_gt.add_vertex(10) @@ -813,37 +809,38 @@ def test_networkx_signal_import(self): self.assertEqual(g.signals["signal1"][i], nx.get_node_attributes(g_nx, "signal1")[node]) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): - if sys.version_info >= (3, 6): - graph = graphs.Sensor(seed=42) - np.random.seed(42) - signal = np.random.random(graph.N) - graph.set_signal(signal, "signal") - - # save - nx_gt = ['gml', 'graphml'] - all_files = [] - for fmt in nx_gt: - all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph_tool') - graph.save("graph_nx.{}".format(fmt), backend='networkx') - graph.save("graph_nx.{}".format('gexf'), backend='networkx') - all_files += ["graph_nx.{}".format('gexf')] - - # load - for filename in all_files: - if not "_gt" in filename: - graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') - np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) - if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) - np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) - - # clean - for filename in all_files: - os.remove(filename) + + graph = graphs.Sensor(seed=42) + np.random.seed(42) + signal = np.random.random(graph.N) + graph.set_signal(signal, "signal") + + # save + nx_gt = ['gml', 'graphml'] + all_files = [] + for fmt in nx_gt: + all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] + graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_nx.{}".format(fmt), backend='networkx') + graph.save("graph_nx.{}".format('gexf'), backend='networkx') + all_files += ["graph_nx.{}".format('gexf')] + + # load + for filename in all_files: + if not "_gt" in filename: + graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') + np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) + if not ".gexf" in filename: + graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) + np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) + + # clean + for filename in all_files: + os.remove(filename) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 96db5359dfca07b0002fc03de62adfe2974c3bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 00:32:10 +0100 Subject: [PATCH 179/365] show helpful message if networkx or graph-tool cannot be imported --- pygsp/graphs/graph.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 52b7cea0..018659bb 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -11,6 +11,25 @@ from . import fourier, difference # prevent circular import in Python < 3.5 +def _import_networkx(): + try: + import networkx as nx + except Exception as e: + raise ImportError('Cannot import networkx. Use graph-tool or try to ' + 'install it with pip (or conda) install networkx. ' + 'Original exception: {}'.format(e)) + return nx + + +def _import_graphtool(): + try: + import graph_tool as gt + except Exception as e: + raise ImportError('Cannot import graph-tool. Use networkx or try to ' + 'install it. Original exception: {}'.format(e)) + return gt + + class Graph(fourier.GraphFourier, difference.GraphDifference): r"""Base graph class. From 964a6da662f0ccd3c1f4dd86eeacda4fddd393ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 01:22:56 +0100 Subject: [PATCH 180/365] test import errors --- pygsp/tests/test_graphs.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 9106bde1..fd9c1006 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -7,11 +7,10 @@ from __future__ import division -import sys -import unittest -import random import os +import random import sys +import unittest import numpy as np import scipy.linalg @@ -670,7 +669,7 @@ def test_grid2dimgpatches(self): suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) -class TestCaseImportExport(unittest.TestCase): +class TestImportExport(unittest.TestCase): def test_networkx_export_import(self): # Export to networkx and reimport to PyGSP @@ -843,5 +842,34 @@ def test_save_load(self): os.remove(filename) -suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) + @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') + def test_import_errors(self): + from unittest.mock import patch + graph = graphs.Sensor() + filename = 'graph.gml' + with patch.dict(sys.modules, {'networkx': None}): + self.assertRaises(ImportError, graph.to_networkx) + self.assertRaises(ImportError, graphs.Graph.from_networkx, None) + self.assertRaises(ImportError, graph.save, filename, + backend='networkx') + self.assertRaises(ImportError, graphs.Graph.load, filename, + backend='networkx') + graph.save(filename) + graphs.Graph.load(filename) + with patch.dict(sys.modules, {'graph_tool': None}): + self.assertRaises(ImportError, graph.to_graphtool) + self.assertRaises(ImportError, graphs.Graph.from_graphtool, None) + self.assertRaises(ImportError, graph.save, filename, + backend='graph_tool') + self.assertRaises(ImportError, graphs.Graph.load, filename, + backend='graph_tool') + graph.save(filename) + graphs.Graph.load(filename) + with patch.dict(sys.modules, {'networkx': None, 'graph_tool': None}): + self.assertRaises(ImportError, graph.save, filename) + self.assertRaises(ImportError, graphs.Graph.load, filename) + os.remove(filename) + + +suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestImportExport) suite = unittest.TestSuite([suite_graphs, suite_import_export]) From 2ea145de792fb096c7b78042b22570b666b29d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 14:37:40 +0100 Subject: [PATCH 181/365] improve save and load --- pygsp/graphs/graph.py | 1 + pygsp/tests/test_graphs.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 018659bb..e8cc6ded 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -2,6 +2,7 @@ from __future__ import division +import os from collections import Counter import numpy as np diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index fd9c1006..60b77d4f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -821,7 +821,7 @@ def test_save_load(self): all_files = [] for fmt in nx_gt: all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_gt.{}".format(fmt), backend='graph-tool') graph.save("graph_nx.{}".format(fmt), backend='networkx') graph.save("graph_nx.{}".format('gexf'), backend='networkx') all_files += ["graph_nx.{}".format('gexf')] @@ -833,7 +833,7 @@ def test_save_load(self): np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + graph_loaded_gt = graphs.Graph.load(filename, backend='graph-tool') np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) @@ -860,9 +860,9 @@ def test_import_errors(self): self.assertRaises(ImportError, graph.to_graphtool) self.assertRaises(ImportError, graphs.Graph.from_graphtool, None) self.assertRaises(ImportError, graph.save, filename, - backend='graph_tool') + backend='graph-tool') self.assertRaises(ImportError, graphs.Graph.load, filename, - backend='graph_tool') + backend='graph-tool') graph.save(filename) graphs.Graph.load(filename) with patch.dict(sys.modules, {'networkx': None, 'graph_tool': None}): From 4a248bf84d7072918532bc1849b8e008436590e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 05:40:03 +0100 Subject: [PATCH 182/365] use RandomState instead of seeding whole numpy --- pygsp/tests/test_graphs.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 60b77d4f..a12dd4ae 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -745,9 +745,9 @@ def key(edge): return str(edge.source()) + ":" + str(edge.target()) def test_networkx_signal_export(self): graph = graphs.BarabasiAlbert(N=100, seed=42) - np.random.seed(42) - signal1 = np.random.random(graph.N) - signal2 = np.random.random(graph.N) + rs = np.random.RandomState(42) + signal1 = rs.normal(size=graph.N) + signal2 = rs.normal(size=graph.N) graph.set_signal(signal1, "signal1") graph.set_signal(signal2, "signal2") graph_nx = graph.to_networkx() @@ -757,9 +757,9 @@ def test_networkx_signal_export(self): def test_graphtool_signal_export(self): g = graphs.Logo() - np.random.seed(42) - s = np.random.random(g.N) - s2 = np.random.random(g.N) + rs = np.random.RandomState(42) + s = rs.normal(size=g.N) + s2 = rs.normal(size=g.N) g.set_signal(s, "signal1") g.set_signal(s2, "signal2") g_gt = g.to_graphtool() @@ -812,8 +812,7 @@ def test_networkx_signal_import(self): def test_save_load(self): graph = graphs.Sensor(seed=42) - np.random.seed(42) - signal = np.random.random(graph.N) + signal = np.random.RandomState(42).uniform(size=graph.N) graph.set_signal(signal, "signal") # save From ce9f5e0f936a42fb7502c40c2d7873b47f58d6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 05:40:36 +0100 Subject: [PATCH 183/365] better test of save and load --- pygsp/tests/test_graphs.py | 57 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a12dd4ae..785f22e8 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -811,35 +811,34 @@ def test_networkx_signal_import(self): @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): - graph = graphs.Sensor(seed=42) - signal = np.random.RandomState(42).uniform(size=graph.N) - graph.set_signal(signal, "signal") - - # save - nx_gt = ['gml', 'graphml'] - all_files = [] - for fmt in nx_gt: - all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph-tool') - graph.save("graph_nx.{}".format(fmt), backend='networkx') - graph.save("graph_nx.{}".format('gexf'), backend='networkx') - all_files += ["graph_nx.{}".format('gexf')] - - # load - for filename in all_files: - if not "_gt" in filename: - graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') - np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) - if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph-tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) - np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) - - # clean - for filename in all_files: - os.remove(filename) - + G1 = graphs.Sensor(seed=42) + W = G1.W.toarray() + sig = np.random.RandomState(42).normal(size=G1.N) + G1.set_signal(sig, 's') + + for fmt in ['graphml', 'gml', 'gexf']: + for backend in ['networkx', 'graph-tool']: + + if fmt == 'gexf' and backend == 'graph-tool': + self.assertRaises(ValueError, G1.save, 'g', fmt, backend) + self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt, + backend) + continue + + atol = 1e-5 if fmt == 'gml' and backend == 'graph-tool' else 0 + + for filename, fmt in [('graph.' + fmt, None), ('graph', fmt)]: + G1.save(filename, fmt, backend) + G2 = graphs.Graph.load(filename, fmt, backend) + np.testing.assert_allclose(G2.W.toarray(), W, atol=atol) + np.testing.assert_allclose(G2.signals['s'], sig, atol=atol) + os.remove(filename) + + self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt='unk') + self.assertRaises(ValueError, graphs.Graph.load, 'g', backend='unk') + self.assertRaises(ValueError, G1.save, 'g', fmt='unk') + self.assertRaises(ValueError, G1.save, 'g', backend='unk') + os.remove('g') @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') def test_import_errors(self): From 67782999d61240192bf008acef3a84847b5fd5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Mar 2019 04:35:24 +0100 Subject: [PATCH 184/365] clean & improve {to,from}_{networkx,graphtool} --- pygsp/tests/test_utils.py | 6 ------ pygsp/utils.py | 41 --------------------------------------- 2 files changed, 47 deletions(-) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index a7298319..af23f4b8 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -32,11 +32,5 @@ def test_symmetrize(self): np.testing.assert_equal(W1.toarray(), W2) self.assertRaises(ValueError, utils.symmetrize, W, 'sum') - def test_convert_dtype(self): - signal = np.zeros(10, dtype=np.int16) - self.assertEqual(utils.convert_dtype(signal.dtype), 'int16_t') - signal = np.zeros(10, dtype=np.float128) - self.assertEqual(utils.convert_dtype(signal.dtype), None) - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/utils.py b/pygsp/utils.py index 70fbfcc9..94474eb1 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -364,44 +364,3 @@ def import_functions(names, src, dst): for name in names: module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) - - -def convert_dtype(dtype): - r"""Convert from numpy dtype to graph tool types. - - The supported numpy types are: ``bool_``, ``int_``, ``int16``, ``int32``, - ``int64``, ``float_``, ``float16``, ``float32``, ``float64``. - - See the `graph-tool documentation - `_ - for details. - - Parameters - ---------- - dtype : :class:`numpy.dtype` - Numpy data type. - - Returns - ------- - type : string - A string representing the type, ready to be used by graph-tool. - - """ - translation = { - np.bool_: 'bool', - np.int_: 'int', - np.int16: 'int16_t', - np.int32: 'int32_t', - np.int64: 'int64_t', - np.float_: 'long double', - np.float16: 'double', - np.float32: 'double', - np.float64: 'long double' - } - - try: - graph_tool_type = translation[dtype.type] - except KeyError: - graph_tool_type = None - - return graph_tool_type From cf8f7411f8915cab7c2ca14ba55effbd679e1964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 12:34:49 +0100 Subject: [PATCH 185/365] test import/export without edge weights --- pygsp/tests/test_graphs.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 785f22e8..10baa57e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -808,6 +808,42 @@ def test_networkx_signal_import(self): self.assertEqual(g.signals["signal1"][i], nx.get_node_attributes(g_nx, "signal1")[node]) + def test_no_weights(self): + + adjacency = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + + # NetworkX no weights. + graph_nx = nx.Graph() + graph_nx.add_edge(0, 1) + graph_nx.add_edge(1, 2) + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # NetworkX non-existent weight name. + graph_nx.edges[(0, 1)]['weight'] = 2 + graph_nx.edges[(1, 2)]['weight'] = 2 + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) + graph_pg = graphs.Graph.from_networkx(graph_nx, weight='unknown') + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # Graph-tool no weights. + graph_gt = gt.Graph(directed=False) + graph_gt.add_edge(0, 1) + graph_gt.add_edge(1, 2) + graph_pg = graphs.Graph.from_graphtool(graph_gt) + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # Graph-tool non-existent weight name. + prop = graph_gt.new_edge_property("double") + prop[(0, 1)] = 2 + prop[(1, 2)] = 2 + graph_gt.edge_properties["weight"] = prop + graph_pg = graphs.Graph.from_graphtool(graph_gt) + np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) + graph_pg = graphs.Graph.from_graphtool(graph_gt, weight='unknown') + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From 2b945db6234c4a1d9ff4d63644b632b9942cd864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 12:38:02 +0100 Subject: [PATCH 186/365] more simple and complete from_graphtool example --- pygsp/graphs/graph.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e8cc6ded..6e0b71b0 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -262,32 +262,6 @@ def check_weights(self): 'is_not_square': is_not_square, 'diag_is_not_zero': diag_is_not_zero} - def set_signal(self, signal, name): - r"""Attach a signal to the graph. - - Attached signals can be accessed (and modified or deleted) through the - :attr:`signals` dictionary. - - Parameters - ---------- - signal : array_like - A sequence that assigns a value to each vertex. - The value of the signal at vertex `i` is ``signal[i]``. - name : String - Name of the signal used as a key in the :attr:`signals` dictionary. - - Examples - -------- - >>> graph = graphs.Sensor(10) - >>> signal = np.arange(graph.n_vertices) - >>> graph.set_signal(signal, 'mysignal') - >>> graph.signals - {'mysignal': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])} - - """ - signal = self._check_signal(signal) - self.signals[name] = signal - def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). From 615650d96e3e86fa78969fa1de7d2202be8c1106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 13:24:14 +0100 Subject: [PATCH 187/365] contributing: extras requirement and partial test suite --- CONTRIBUTING.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f2ed7790..f18d202d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -16,6 +16,11 @@ development with the following:: $ git clone https://github.com/epfl-lts2/pygsp.git $ pip install -U -e pygsp[dev] +The ``dev`` "extras requirement" ensures that dependencies required for +development (to run the test suite and build the documentation) are installed. +Only `graph-tool `_ will be missing: install it +manually as it cannot be installed by pip. + You can improve or add functionality in the ``pygsp`` folder, along with corresponding unit tests in ``pygsp/tests/test_*.py`` (with reasonable coverage) and documentation in ``doc/reference/*.rst``. If you have a nice @@ -36,6 +41,13 @@ documentation with the following (enforced by Travis CI):: Check the generated coverage report at ``htmlcov/index.html`` to make sure the tests reasonably cover the changes you've introduced. +To iterate faster, you can partially run the test suite, at various degrees of +granularity, as follows:: + + $ python -m unittest pygsp.tests.test_docstrings.suite_reference + $ python -m unittest pygsp.tests.test_graphs.TestImportExport + $ python -m unittest pygsp.tests.test_graphs.TestImportExport.test_save_load + Making a release ---------------- From 0ed20e92a1287330cad5b485334b628f64110dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 14:31:55 +0100 Subject: [PATCH 188/365] break and join signals on I/O --- pygsp/tests/test_graphs.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 10baa57e..9db0ac85 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -844,6 +844,25 @@ def test_no_weights(self): graph_pg = graphs.Graph.from_graphtool(graph_gt, weight='unknown') np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + def test_break_join_signals(self): + """Multi-dim signals are broken on export and joined on import.""" + graph_1 = graphs.Sensor(20, seed=42) + graph_1.set_signal(graph_1.coords, 'coords') + # networkx + graph_2 = graph_1.to_networkx() + graph_2 = graphs.Graph.from_networkx(graph_2) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + # graph-tool + graph_2 = graph_1.to_graphtool() + graph_2 = graphs.Graph.from_graphtool(graph_2) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + # save and load + filename = 'graph.graphml' + graph_1.save(filename) + graph_2 = graphs.Graph.load(filename) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + os.remove(filename) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From 714fdc04f71ae81b49a5d68a21be688c21b60a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 14:55:41 +0100 Subject: [PATCH 189/365] sorted dict fix for python < 3.6 --- pygsp/tests/test_graphs.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 9db0ac85..9bd73887 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -846,22 +846,23 @@ def test_no_weights(self): def test_break_join_signals(self): """Multi-dim signals are broken on export and joined on import.""" - graph_1 = graphs.Sensor(20, seed=42) - graph_1.set_signal(graph_1.coords, 'coords') + graph1 = graphs.Sensor(20, seed=42) + graph1.set_signal(graph1.coords, 'coords') # networkx - graph_2 = graph_1.to_networkx() - graph_2 = graphs.Graph.from_networkx(graph_2) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + graph2 = graph1.to_networkx() + graph2 = graphs.Graph.from_networkx(graph2) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) # graph-tool - graph_2 = graph_1.to_graphtool() - graph_2 = graphs.Graph.from_graphtool(graph_2) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) - # save and load - filename = 'graph.graphml' - graph_1.save(filename) - graph_2 = graphs.Graph.load(filename) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) - os.remove(filename) + graph2 = graph1.to_graphtool() + graph2 = graphs.Graph.from_graphtool(graph2) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) + # save and load (need ordered dicts) + if sys.version_info >= (3, 6): + filename = 'graph.graphml' + graph1.save(filename) + graph2 = graphs.Graph.load(filename) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) + os.remove(filename) @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From 1b08b1b98214c892fb38758a2ed759f1d21408b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 15:20:41 +0100 Subject: [PATCH 190/365] update I/O doc --- README.rst | 9 +++++++++ pygsp/graphs/__init__.py | 15 +++++++++++++++ pygsp/tests/test_graphs.py | 6 +++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 0c1b1c49..1209c76f 100644 --- a/README.rst +++ b/README.rst @@ -54,6 +54,15 @@ exponential window; and Gabor filters. Despite all the pre-defined models, you can easily use a custom graph by defining its adjacency matrix, and a custom filter bank by defining a set of functions in the spectral domain. +While NetworkX_ and graph-tool_ are tools to analyze the topology of graphs, +the aim of the PyGSP is to analyze graph signals, also known as features or +properties (i.e., not the graph itself). +Those three tools are complementary and work well together with the provided +import / export facility. + +.. _NetworkX: https://networkx.github.io +.. _graph-tool: https://graph-tool.skewed.de + The following demonstrates how to instantiate a graph and a filter, the two main objects of the package. diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 08808401..0f746a33 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -90,6 +90,21 @@ Import and export (I/O) ----------------------- +We provide import and export facility to two well-known Python packages for +network analysis: NetworkX_ and graph-tool_. +Those packages and the PyGSP are fundamentally different in their goals (graph +analysis versus graph signal analysis) and graph representations (if in the +PyGSP everything is an ndarray, in NetworkX everything is a dictionary). +Those tools are complementary and good interoperability is necessary to exploit +the strengths of each tool. +We ourselves leverage NetworkX and graph-tool to save and load graphs. + +Note: to tie a signal with the graph, such that they are exported together, +attach it first with :meth:`Graph.set_signal`. + +.. _NetworkX: https://networkx.github.io +.. _graph-tool: https://graph-tool.skewed.de + .. autosummary:: Graph.load diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 9bd73887..962df6a2 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -701,7 +701,7 @@ def test_graphtool_export_import(self): def test_graphtool_multiedge_import(self): # Manualy create a graph with multiple edges g_gt = gt.Graph() - g_gt.add_vertex(10) + g_gt.add_vertex(n=10) # connect edge (3,6) three times for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) @@ -867,6 +867,10 @@ def test_break_join_signals(self): @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): + # TODO: test with multiple graphs and signals + # * dtypes (float, int, bool) of adjacency and signals + # * empty graph / isolated nodes + G1 = graphs.Sensor(seed=42) W = G1.W.toarray() sig = np.random.RandomState(42).normal(size=G1.N) From ed545cfc68a52b3c819cafdd8199e530c900efa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 15:46:28 +0100 Subject: [PATCH 191/365] move I/O and layout in their own modules --- pygsp/graphs/_io.py | 565 +++++++++++++++++++++++++++++++++++++ pygsp/graphs/_layout.py | 203 +++++++++++++ pygsp/graphs/difference.py | 2 +- pygsp/graphs/fourier.py | 2 +- pygsp/graphs/graph.py | 223 +-------------- 5 files changed, 775 insertions(+), 220 deletions(-) create mode 100644 pygsp/graphs/_io.py create mode 100644 pygsp/graphs/_layout.py diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py new file mode 100644 index 00000000..9748ea3b --- /dev/null +++ b/pygsp/graphs/_io.py @@ -0,0 +1,565 @@ +# -*- coding: utf-8 -*- + +import os + +import numpy as np + + +def _import_networkx(): + try: + import networkx as nx + except Exception as e: + raise ImportError('Cannot import networkx. Use graph-tool or try to ' + 'install it with pip (or conda) install networkx. ' + 'Original exception: {}'.format(e)) + return nx + + +def _import_graphtool(): + try: + import graph_tool as gt + except Exception as e: + raise ImportError('Cannot import graph-tool. Use networkx or try to ' + 'install it. Original exception: {}'.format(e)) + return gt + + +class IOMixIn(object): + + def _break_signals(self): + r"""Break N-dimensional signals into N 1D signals.""" + for name in list(self.signals.keys()): + if self.signals[name].ndim == 2: + for i, signal_1d in enumerate(self.signals[name].T): + self.signals[name + '_' + str(i)] = signal_1d + del self.signals[name] + + def _join_signals(self): + r"""Join N 1D signals into one N-dimensional signal.""" + joined = dict() + for name in self.signals: + name_base = name.rsplit('_', 1)[0] + names = joined.get(name_base, list()) + names.append(name) + joined[name_base] = names + for name_base, names in joined.items(): + if len(names) > 1: + names = sorted(names) # ensure dim ordering (_0, _1, etc.) + signal_nd = np.stack([self.signals[n] for n in names], axis=1) + self.signals[name_base] = signal_nd + for name in names: + del self.signals[name] + + def to_networkx(self): + r"""Export the graph to NetworkX. + + Edge weights are stored as an edge attribute, + under the name "weight". + + Signals are stored as node attributes, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Returns + ------- + graph : :class:`networkx.Graph` + A NetworkX graph object. + + See also + -------- + to_graphtool : export to graph-tool + save : save to a file + + Examples + -------- + >>> import networkx as nx + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Path(4, directed=True) + >>> graph.set_signal(np.full(4, 2.3), 'signal') + >>> graph = graph.to_networkx() + >>> print(nx.info(graph)) + Name: Path + Type: DiGraph + Number of nodes: 4 + Number of edges: 3 + Average in degree: 0.7500 + Average out degree: 0.7500 + >>> nx.is_directed(graph) + True + >>> graph.nodes() + NodeView((0, 1, 2, 3)) + >>> graph.edges() + OutEdgeView([(0, 1), (1, 2), (2, 3)]) + >>> graph.nodes()[2] + {'signal': 2.3} + >>> graph.edges()[(0, 1)] + {'weight': 1.0} + >>> # nx.draw(graph, with_labels=True) + + Another common goal is to use NetworkX to compute some properties to be + be imported back in the PyGSP as signals. + + >>> import networkx as nx + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Sensor(100, seed=42) + >>> graph.set_signal(graph.coords, 'coords') + >>> graph = graph.to_networkx() + >>> betweenness = nx.betweenness_centrality(graph, weight='weight') + >>> nx.set_node_attributes(graph, betweenness, 'betweenness') + >>> graph = graphs.Graph.from_networkx(graph) + >>> graph.compute_fourier_basis() + >>> graph.set_coordinates(graph.signals['coords']) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) + >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) + + """ + nx = _import_networkx() + + def convert(number): + # NetworkX accepts arbitrary python objects as attributes, but: + # * the GEXF writer does not accept any NumPy types (on signals), + # * the GraphML writer does not accept NumPy ints. + if issubclass(number.dtype.type, (np.integer, np.bool_)): + return int(number) + else: + return float(number) + + def edges(): + for source, target, weight in zip(*self.get_edge_list()): + yield source, target, {'weight': convert(weight)} + + def nodes(): + for vertex in range(self.n_vertices): + signals = {name: convert(signal[vertex]) + for name, signal in self.signals.items()} + yield vertex, signals + + self._break_signals() + graph = nx.DiGraph() if self.is_directed() else nx.Graph() + graph.add_nodes_from(nodes()) + graph.add_edges_from(edges()) + graph.name = self.__class__.__name__ + return graph + + def to_graphtool(self): + r"""Export the graph to graph-tool. + + Edge weights are stored as an edge property map, + under the name "weight". + + Signals are stored as vertex property maps, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Returns + ------- + graph : :class:`graph_tool.Graph` + A graph-tool graph object. + + See also + -------- + to_networkx : export to NetworkX + save : save to a file + + Examples + -------- + >>> import graph_tool as gt + >>> import graph_tool.draw + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Path(4, directed=True) + >>> graph.set_signal(np.full(4, 2.3), 'signal') + >>> graph = graph.to_graphtool() + >>> graph.is_directed() + True + >>> graph.vertex_properties['signal'][2] + 2.3 + >>> graph.edge_properties['weight'][(0, 1)] + 1.0 + >>> # gt.draw.graph_draw(graph, vertex_text=graph.vertex_index) + + Another common goal is to use graph-tool to compute some properties to + be imported back in the PyGSP as signals. + + >>> import graph_tool as gt + >>> import graph_tool.centrality + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Sensor(100, seed=42) + >>> graph.set_signal(graph.coords, 'coords') + >>> graph = graph.to_graphtool() + >>> vprop, eprop = gt.centrality.betweenness( + ... graph, weight=graph.edge_properties['weight']) + >>> graph.vertex_properties['betweenness'] = vprop + >>> graph = graphs.Graph.from_graphtool(graph) + >>> graph.compute_fourier_basis() + >>> graph.set_coordinates(graph.signals['coords']) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) + >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) + + """ + + # See gt.value_types() for the list of accepted types. + # See the definition of _type_alias() for a list of aliases. + # Mapping from https://docs.scipy.org/doc/numpy/user/basics.types.html. + convert = { + np.bool_: 'bool', + np.int8: 'int8_t', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.short: 'short', + np.intc: 'int', + np.uintc: 'unsigned int', + np.long: 'long', + np.longlong: 'long long', + np.uint: 'unsigned long', + np.single: 'float', + np.double: 'double', + np.longdouble: 'long double', + } + + gt = _import_graphtool() + graph = gt.Graph(directed=self.is_directed()) + + sources, targets, weights = self.get_edge_list() + graph.add_edge_list(np.asarray((sources, targets)).T) + try: + dtype = convert[weights.dtype.type] + except KeyError: + raise ValueError("Type {} of the edge weights is not supported." + .format(weights.dtype)) + prop = graph.new_edge_property(dtype) + prop.get_array()[:] = weights + graph.edge_properties['weight'] = prop + + self._break_signals() + for name, signal in self.signals.items(): + try: + dtype = convert[signal.dtype.type] + except KeyError: + raise ValueError("Type {} of signal {} is not supported." + .format(signal.dtype, name)) + prop = graph.new_vertex_property(dtype) + prop.get_array()[:] = signal + graph.vertex_properties[name] = prop + + return graph + + @classmethod + def from_networkx(cls, graph, weight='weight'): + r"""Import a graph from NetworkX. + + Edge weights are retrieved as an edge attribute, + under the name specified by the ``weight`` parameter. + + Signals are retrieved from node attributes, + and stored in the :attr:`signals` dictionary under the attribute name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + graph : :class:`networkx.Graph` + A NetworkX graph object. + weight : string or None, optional + The edge attribute that holds the numerical values used as the edge + weights. All edge weights are set to 1 if None, or not found. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + A PyGSP graph object. + + Notes + ----- + + The nodes are ordered according to :meth:`networkx.Graph.nodes`. + + In NetworkX, node attributes need not be set for every node. + If a node attribute is not set for a node, a NaN is assigned to the + corresponding signal for that node. + + If the graph is a :class:`networkx.MultiGraph`, multiedges are + aggregated by summation. + + See also + -------- + from_graphtool : import from graph-tool + load : load from a file + + Examples + -------- + >>> import networkx as nx + >>> graph = nx.Graph() + >>> graph.add_edge(1, 2, weight=0.2) + >>> graph.add_edge(2, 3, weight=0.9) + >>> graph.add_node(4, sig=3.1416) + >>> graph.nodes() + NodeView((1, 2, 3, 4)) + >>> graph = graphs.Graph.from_networkx(graph) + >>> graph.W.toarray() + array([[0. , 0.2, 0. , 0. ], + [0.2, 0. , 0.9, 0. ], + [0. , 0.9, 0. , 0. ], + [0. , 0. , 0. , 0. ]]) + >>> graph.signals + {'sig': array([ nan, nan, nan, 3.1416])} + + """ + nx = _import_networkx() + from .graph import Graph + + adjacency = nx.to_scipy_sparse_matrix(graph, weight=weight) + graph_pg = Graph(adjacency) + + for i, node in enumerate(graph.nodes()): + for name in graph.nodes[node].keys(): + try: + signal = graph_pg.signals[name] + except KeyError: + signal = np.full(graph_pg.n_vertices, np.nan) + graph_pg.set_signal(signal, name) + try: + signal[i] = graph.nodes[node][name] + except KeyError: + pass # attribute not set for node + + graph_pg._join_signals() + return graph_pg + + @classmethod + def from_graphtool(cls, graph, weight='weight'): + r"""Import a graph from graph-tool. + + Edge weights are retrieved as an edge property, + under the name specified by the ``weight`` parameter. + + Signals are retrieved from node properties, + and stored in the :attr:`signals` dictionary under the property name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + graph : :class:`graph_tool.Graph` + A graph-tool graph object. + weight : string + The edge property that holds the numerical values used as the edge + weights. All edge weights are set to 1 if None, or not found. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + A PyGSP graph object. + + Notes + ----- + + If the graph has multiple edge connecting the same two nodes, a sum + over the edges is taken to merge them. + + See also + -------- + from_networkx : import from NetworkX + load : load from a file + + Examples + -------- + >>> import graph_tool as gt + >>> graph = gt.Graph(directed=False) + >>> e1 = graph.add_edge(0, 1) + >>> e2 = graph.add_edge(1, 2) + >>> v = graph.add_vertex() + >>> eprop = graph.new_edge_property("double") + >>> eprop[e1] = 0.2 + >>> eprop[(1, 2)] = 0.9 + >>> graph.edge_properties["weight"] = eprop + >>> vprop = graph.new_vertex_property("double", val=np.nan) + >>> vprop[3] = 3.1416 + >>> graph.vertex_properties["sig"] = vprop + >>> graph = graphs.Graph.from_graphtool(graph) + >>> graph.W.toarray() + array([[0. , 0.2, 0. , 0. ], + [0.2, 0. , 0.9, 0. ], + [0. , 0.9, 0. , 0. ], + [0. , 0. , 0. , 0. ]]) + >>> graph.signals + {'sig': PropertyArray([ nan, nan, nan, 3.1416])} + + """ + gt = _import_graphtool() + import graph_tool.spectral + from .graph import Graph + + weight = graph.edge_properties.get(weight, None) + adjacency = gt.spectral.adjacency(graph, weight=weight) + graph_pg = Graph(adjacency.T) + + for name, signal in graph.vertex_properties.items(): + graph_pg.set_signal(signal.get_array(), name) + + graph_pg._join_signals() + return graph_pg + + @classmethod + def load(cls, path, fmt=None, backend=None): + r"""Load a graph from a file. + + Edge weights are retrieved as an edge attribute named "weight". + + Signals are retrieved from node attributes, + and stored in the :attr:`signals` dictionary under the attribute name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + path : string + Path to the file from which to load the graph. + fmt : {'graphml', 'gml', 'gexf', None}, optional + Format in which the graph is saved. + Guessed from the filename extension if None. + backend : {'networkx', 'graph-tool', None}, optional + Library used to load the graph. Automatically chosen if None. + + Returns + ------- + graph : :class:`Graph` + The loaded graph. + + See also + -------- + save : save a graph to a file + from_networkx : load with NetworkX then import in the PyGSP + from_graphtool : load with graph-tool then import in the PyGSP + + Notes + ----- + + A lossless round-trip is only guaranteed if the graph (and its signals) + is saved and loaded with the same backend. + + Loading from other formats is possible by loading in NetworkX or + graph-tool, and importing to the PyGSP. + The proposed formats are however tested for faithful round-trips. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') + >>> graph = graphs.Graph.load('logo.graphml') + >>> import os + >>> os.remove('logo.graphml') + + """ + + if fmt is None: + fmt = os.path.splitext(path)[1][1:] + if fmt not in ['graphml', 'gml', 'gexf']: + raise ValueError('Unsupported format {}.'.format(fmt)) + + def load_networkx(path, fmt): + nx = _import_networkx() + load = getattr(nx, 'read_' + fmt) + graph = load(path) + return cls.from_networkx(graph) + + def load_graphtool(path, fmt): + gt = _import_graphtool() + graph = gt.load_graph(path, fmt=fmt) + return cls.from_graphtool(graph) + + if backend == 'networkx': + return load_networkx(path, fmt) + elif backend == 'graph-tool': + return load_graphtool(path, fmt) + elif backend is None: + try: + return load_networkx(path, fmt) + except ImportError: + try: + return load_graphtool(path, fmt) + except ImportError: + raise ImportError('Cannot import networkx nor graph-tool.') + else: + raise ValueError('Unknown backend {}.'.format(backend)) + + def save(self, path, fmt=None, backend=None): + r"""Save the graph to a file. + + Edge weights are stored as an edge attribute, + under the name "weight". + + Signals are stored as node attributes, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Parameters + ---------- + path : string + Path to the file where the graph is to be saved. + fmt : {'graphml', 'gml', 'gexf', None}, optional + Format in which to save the graph. + Guessed from the filename extension if None. + backend : {'networkx', 'graph-tool', None}, optional + Library used to load the graph. Automatically chosen if None. + + See also + -------- + load : load a graph from a file + to_networkx : export as a NetworkX graph, and save with NetworkX + to_graphtool : export as a graph-tool graph, and save with graph-tool + + Notes + ----- + + A lossless round-trip is only guaranteed if the graph (and its signals) + is saved and loaded with the same backend. + + Saving in other formats is possible by exporting to NetworkX or + graph-tool, and using their respective saving functionality. + The proposed formats are however tested for faithful round-trips. + + Edge weights and signal values are rounded at the sixth decimal when + saving in ``fmt='gml'`` with ``backend='graph-tool'``. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') + >>> graph = graphs.Graph.load('logo.graphml') + >>> import os + >>> os.remove('logo.graphml') + + """ + + if fmt is None: + fmt = os.path.splitext(path)[1][1:] + if fmt not in ['graphml', 'gml', 'gexf']: + raise ValueError('Unsupported format {}.'.format(fmt)) + + def save_networkx(graph, path, fmt): + nx = _import_networkx() + graph = graph.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph, path) + + def save_graphtool(graph, path, fmt): + graph = graph.to_graphtool() + graph.save(path, fmt=fmt) + + if backend == 'networkx': + save_networkx(self, path, fmt) + elif backend == 'graph-tool': + save_graphtool(self, path, fmt) + elif backend is None: + try: + save_networkx(self, path, fmt) + except ImportError: + try: + save_graphtool(self, path, fmt) + except ImportError: + raise ImportError('Cannot import networkx nor graph-tool.') + else: + raise ValueError('Unknown backend {}.'.format(backend)) diff --git a/pygsp/graphs/_layout.py b/pygsp/graphs/_layout.py new file mode 100644 index 00000000..b20fcf89 --- /dev/null +++ b/pygsp/graphs/_layout.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- + +import numpy as np + + +class LayoutMixIn(object): + + def set_coordinates(self, kind='spring', **kwargs): + r"""Set node's coordinates (their position when plotting). + + Parameters + ---------- + kind : string or array_like + Kind of coordinates to generate. It controls the position of the + nodes when plotting the graph. Can either pass an array of size Nx2 + or Nx3 to set the coordinates manually or the name of a layout + algorithm. Available algorithms: community2D, random2D, random3D, + ring2D, line1D, spring, laplacian_eigenmap2D, laplacian_eigenmap3D. + Default is 'spring'. + kwargs : dict + Additional parameters to be passed to the Fruchterman-Reingold + force-directed algorithm when kind is spring. + + Examples + -------- + >>> G = graphs.ErdosRenyi() + >>> G.set_coordinates() + >>> fig, ax = G.plot() + + """ + + if not isinstance(kind, str): + coords = np.asanyarray(kind).squeeze() + check_1d = (coords.ndim == 1) + check_2d_3d = (coords.ndim == 2) and (2 <= coords.shape[1] <= 3) + if coords.shape[0] != self.N or not (check_1d or check_2d_3d): + raise ValueError('Expecting coordinates to be of size N, Nx2, ' + 'or Nx3.') + self.coords = coords + + elif kind == 'line1D': + self.coords = np.arange(self.N) + + elif kind == 'line2D': + x, y = np.arange(self.N), np.zeros(self.N) + self.coords = np.stack([x, y], axis=1) + + elif kind == 'ring2D': + angle = np.arange(self.N) * 2 * np.pi / self.N + self.coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) + + elif kind == 'random2D': + self.coords = np.random.uniform(size=(self.N, 2)) + + elif kind == 'random3D': + self.coords = np.random.uniform(size=(self.N, 3)) + + elif kind == 'spring': + self.coords = self._fruchterman_reingold_layout(**kwargs) + + elif kind == 'community2D': + if not hasattr(self, 'info') or 'node_com' not in self.info: + ValueError('Missing arguments to the graph to be able to ' + 'compute community coordinates.') + + if 'world_rad' not in self.info: + self.info['world_rad'] = np.sqrt(self.N) + + if 'comm_sizes' not in self.info: + counts = Counter(self.info['node_com']) + self.info['comm_sizes'] = np.array([cnt[1] for cnt + in sorted(counts.items())]) + + Nc = self.info['comm_sizes'].shape[0] + + self.info['com_coords'] = self.info['world_rad'] * \ + np.array(list(zip( + np.cos(2 * np.pi * np.arange(1, Nc + 1) / Nc), + np.sin(2 * np.pi * np.arange(1, Nc + 1) / Nc)))) + + # Coordinates of the nodes inside their communities + coords = np.random.rand(self.N, 2) + self.coords = np.array([[elem[0] * np.cos(2 * np.pi * elem[1]), + elem[0] * np.sin(2 * np.pi * elem[1])] + for elem in coords]) + + for i in range(self.N): + # Set coordinates as an offset from the center of the community + # it belongs to + comm_idx = self.info['node_com'][i] + comm_rad = np.sqrt(self.info['comm_sizes'][comm_idx]) + self.coords[i] = self.info['com_coords'][comm_idx] + \ + comm_rad * self.coords[i] + elif kind == 'laplacian_eigenmap2D': + self.compute_fourier_basis(n_eigenvectors=2) + self.coords = self.U[:, 1:3] + elif kind == 'laplacian_eigenmap3D': + self.compute_fourier_basis(n_eigenvectors=3) + self.coords = self.U[:, 1:4] + else: + raise ValueError('Unexpected argument kind={}.'.format(kind)) + + def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], + iterations=50, scale=1.0, center=None, + seed=None): + # TODO doc + # fixed: list of nodes with fixed coordinates + # Position nodes using Fruchterman-Reingold force-directed algorithm. + + if center is None: + center = np.zeros((1, dim)) + + if np.shape(center)[1] != dim: + self.logger.error('Spring coordinates: center has wrong size.') + center = np.zeros((1, dim)) + + if pos is None: + dom_size = 1 + pos_arr = None + else: + # Determine size of existing domain to adjust initial positions + dom_size = np.max(pos) + pos_arr = np.random.RandomState(seed).uniform(size=(self.N, dim)) + pos_arr = pos_arr * dom_size + center + for i in range(self.N): + pos_arr[i] = np.asanyarray(pos[i]) + + if k is None and len(fixed) > 0: + # We must adjust k by domain size for layouts that are not near 1x1 + k = dom_size / np.sqrt(self.N) + + pos = _sparse_fruchterman_reingold(self.A, dim, k, pos_arr, + fixed, iterations, seed) + + if len(fixed) == 0: + pos = _rescale_layout(pos, scale=scale) + center + + return pos + + +def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): + # Position nodes in adjacency matrix A using Fruchterman-Reingold + nnodes = A.shape[0] + + # make sure we have a LIst of Lists representation + try: + A = A.tolil() + except Exception: + A = (sparse.coo_matrix(A)).tolil() + + if pos is None: + # random initial positions + pos = np.random.RandomState(seed).uniform(size=(nnodes, dim)) + + # optimal distance between nodes + if k is None: + k = np.sqrt(1.0 / nnodes) + + # simple cooling scheme. + # linearly step down by dt on each iteration so last iteration is size dt. + t = 0.1 + dt = t / float(iterations + 1) + + displacement = np.zeros((dim, nnodes)) + for iteration in range(iterations): + displacement *= 0 + # loop over rows + for i in range(nnodes): + if i in fixed: + continue + # difference between this row's node position and all others + delta = (pos[i] - pos).T + # distance between points + distance = np.sqrt((delta**2).sum(axis=0)) + # enforce minimum distance of 0.01 + distance = np.where(distance < 0.01, 0.01, distance) + # the adjacency matrix row + Ai = A[i, :].toarray() + # displacement "force" + displacement[:, i] += \ + (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) + # update positions + length = np.sqrt((displacement**2).sum(axis=0)) + length = np.where(length < 0.01, 0.1, length) + pos += (displacement * t / length).T + # cool temperature + t -= dt + + return pos + + +def _rescale_layout(pos, scale=1): + # rescale to (-scale, scale) in all axes + + # shift origin to (0,0) + lim = 0 # max coordinate for all axes + for i in range(pos.shape[1]): + pos[:, i] -= pos[:, i].mean() + lim = max(pos[:, i].max(), lim) + # rescale to (-scale,scale) in all directions, preserves aspect + for i in range(pos.shape[1]): + pos[:, i] *= scale / lim + return pos diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 9ce6013b..50c57b03 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -11,7 +11,7 @@ logger = utils.build_logger(__name__) -class GraphDifference(object): +class DifferenceMixIn(object): @property def D(self): diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 52bb87c9..0525bf69 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -9,7 +9,7 @@ logger = utils.build_logger(__name__) -class GraphFourier(object): +class FourierMixIn(object): def _check_fourier_properties(self, name, desc): if getattr(self, '_' + name) is None: diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6e0b71b0..a2ade1be 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -9,29 +9,13 @@ from scipy import sparse from pygsp import utils -from . import fourier, difference # prevent circular import in Python < 3.5 +from .fourier import FourierMixIn +from .difference import DifferenceMixIn +from ._io import IOMixIn +from ._layout import LayoutMixIn -def _import_networkx(): - try: - import networkx as nx - except Exception as e: - raise ImportError('Cannot import networkx. Use graph-tool or try to ' - 'install it with pip (or conda) install networkx. ' - 'Original exception: {}'.format(e)) - return nx - - -def _import_graphtool(): - try: - import graph_tool as gt - except Exception as e: - raise ImportError('Cannot import graph-tool. Use networkx or try to ' - 'install it. Original exception: {}'.format(e)) - return gt - - -class Graph(fourier.GraphFourier, difference.GraphDifference): +class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): r"""Base graph class. * Instantiate it to construct a graph from a (weighted) adjacency matrix. @@ -262,101 +246,6 @@ def check_weights(self): 'is_not_square': is_not_square, 'diag_is_not_zero': diag_is_not_zero} - def set_coordinates(self, kind='spring', **kwargs): - r"""Set node's coordinates (their position when plotting). - - Parameters - ---------- - kind : string or array_like - Kind of coordinates to generate. It controls the position of the - nodes when plotting the graph. Can either pass an array of size Nx2 - or Nx3 to set the coordinates manually or the name of a layout - algorithm. Available algorithms: community2D, random2D, random3D, - ring2D, line1D, spring, laplacian_eigenmap2D, laplacian_eigenmap3D. - Default is 'spring'. - kwargs : dict - Additional parameters to be passed to the Fruchterman-Reingold - force-directed algorithm when kind is spring. - - Examples - -------- - >>> G = graphs.ErdosRenyi() - >>> G.set_coordinates() - >>> fig, ax = G.plot() - - """ - - if not isinstance(kind, str): - coords = np.asanyarray(kind).squeeze() - check_1d = (coords.ndim == 1) - check_2d_3d = (coords.ndim == 2) and (2 <= coords.shape[1] <= 3) - if coords.shape[0] != self.N or not (check_1d or check_2d_3d): - raise ValueError('Expecting coordinates to be of size N, Nx2, ' - 'or Nx3.') - self.coords = coords - - elif kind == 'line1D': - self.coords = np.arange(self.N) - - elif kind == 'line2D': - x, y = np.arange(self.N), np.zeros(self.N) - self.coords = np.stack([x, y], axis=1) - - elif kind == 'ring2D': - angle = np.arange(self.N) * 2 * np.pi / self.N - self.coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) - - elif kind == 'random2D': - self.coords = np.random.uniform(size=(self.N, 2)) - - elif kind == 'random3D': - self.coords = np.random.uniform(size=(self.N, 3)) - - elif kind == 'spring': - self.coords = self._fruchterman_reingold_layout(**kwargs) - - elif kind == 'community2D': - if not hasattr(self, 'info') or 'node_com' not in self.info: - ValueError('Missing arguments to the graph to be able to ' - 'compute community coordinates.') - - if 'world_rad' not in self.info: - self.info['world_rad'] = np.sqrt(self.N) - - if 'comm_sizes' not in self.info: - counts = Counter(self.info['node_com']) - self.info['comm_sizes'] = np.array([cnt[1] for cnt - in sorted(counts.items())]) - - Nc = self.info['comm_sizes'].shape[0] - - self.info['com_coords'] = self.info['world_rad'] * \ - np.array(list(zip( - np.cos(2 * np.pi * np.arange(1, Nc + 1) / Nc), - np.sin(2 * np.pi * np.arange(1, Nc + 1) / Nc)))) - - # Coordinates of the nodes inside their communities - coords = np.random.rand(self.N, 2) - self.coords = np.array([[elem[0] * np.cos(2 * np.pi * elem[1]), - elem[0] * np.sin(2 * np.pi * elem[1])] - for elem in coords]) - - for i in range(self.N): - # Set coordinates as an offset from the center of the community - # it belongs to - comm_idx = self.info['node_com'][i] - comm_rad = np.sqrt(self.info['comm_sizes'][comm_idx]) - self.coords[i] = self.info['com_coords'][comm_idx] + \ - comm_rad * self.coords[i] - elif kind == 'laplacian_eigenmap2D': - self.compute_fourier_basis(n_eigenvectors=2) - self.coords = self.U[:, 1:3] - elif kind == 'laplacian_eigenmap3D': - self.compute_fourier_basis(n_eigenvectors=3) - self.coords = self.U[:, 1:4] - else: - raise ValueError('Unexpected argument kind={}.'.format(kind)) - def subgraph(self, vertices): r"""Create a subgraph from a list of vertices. @@ -1136,105 +1025,3 @@ def plot_spectrogram(self, node_idx=None): r"""Docstring overloaded at import time.""" from pygsp.plotting import _plot_spectrogram _plot_spectrogram(self, node_idx=node_idx) - - def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], - iterations=50, scale=1.0, center=None, - seed=None): - # TODO doc - # fixed: list of nodes with fixed coordinates - # Position nodes using Fruchterman-Reingold force-directed algorithm. - - if center is None: - center = np.zeros((1, dim)) - - if np.shape(center)[1] != dim: - self.logger.error('Spring coordinates: center has wrong size.') - center = np.zeros((1, dim)) - - if pos is None: - dom_size = 1 - pos_arr = None - else: - # Determine size of existing domain to adjust initial positions - dom_size = np.max(pos) - pos_arr = np.random.RandomState(seed).uniform(size=(self.N, dim)) - pos_arr = pos_arr * dom_size + center - for i in range(self.N): - pos_arr[i] = np.asanyarray(pos[i]) - - if k is None and len(fixed) > 0: - # We must adjust k by domain size for layouts that are not near 1x1 - k = dom_size / np.sqrt(self.N) - - pos = _sparse_fruchterman_reingold(self.A, dim, k, pos_arr, - fixed, iterations, seed) - - if len(fixed) == 0: - pos = _rescale_layout(pos, scale=scale) + center - - return pos - - -def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): - # Position nodes in adjacency matrix A using Fruchterman-Reingold - nnodes = A.shape[0] - - # make sure we have a LIst of Lists representation - try: - A = A.tolil() - except Exception: - A = (sparse.coo_matrix(A)).tolil() - - if pos is None: - # random initial positions - pos = np.random.RandomState(seed).uniform(size=(nnodes, dim)) - - # optimal distance between nodes - if k is None: - k = np.sqrt(1.0 / nnodes) - - # simple cooling scheme. - # linearly step down by dt on each iteration so last iteration is size dt. - t = 0.1 - dt = t / float(iterations + 1) - - displacement = np.zeros((dim, nnodes)) - for iteration in range(iterations): - displacement *= 0 - # loop over rows - for i in range(nnodes): - if i in fixed: - continue - # difference between this row's node position and all others - delta = (pos[i] - pos).T - # distance between points - distance = np.sqrt((delta**2).sum(axis=0)) - # enforce minimum distance of 0.01 - distance = np.where(distance < 0.01, 0.01, distance) - # the adjacency matrix row - Ai = A[i, :].toarray() - # displacement "force" - displacement[:, i] += \ - (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) - # update positions - length = np.sqrt((displacement**2).sum(axis=0)) - length = np.where(length < 0.01, 0.1, length) - pos += (displacement * t / length).T - # cool temperature - t -= dt - - return pos - - -def _rescale_layout(pos, scale=1): - # rescale to (-scale, scale) in all axes - - # shift origin to (0,0) - lim = 0 # max coordinate for all axes - for i in range(pos.shape[1]): - pos[:, i] -= pos[:, i].mean() - lim = max(pos[:, i].max(), lim) - # rescale to (-scale,scale) in all directions, preserves aspect - for i in range(pos.shape[1]): - pos[:, i] *= scale / lim - return pos From 40f234dd4e1b681a347c1932601d00de93eb78f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:03:46 +0100 Subject: [PATCH 192/365] test: fix unknown backend --- pygsp/tests/test_graphs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 962df6a2..b9114270 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -883,6 +883,7 @@ def test_save_load(self): self.assertRaises(ValueError, G1.save, 'g', fmt, backend) self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt, backend) + os.remove('g') continue atol = 1e-5 if fmt == 'gml' and backend == 'graph-tool' else 0 @@ -894,11 +895,10 @@ def test_save_load(self): np.testing.assert_allclose(G2.signals['s'], sig, atol=atol) os.remove(filename) - self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt='unk') - self.assertRaises(ValueError, graphs.Graph.load, 'g', backend='unk') - self.assertRaises(ValueError, G1.save, 'g', fmt='unk') - self.assertRaises(ValueError, G1.save, 'g', backend='unk') - os.remove('g') + self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', fmt='?') + self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', backend='?') + self.assertRaises(ValueError, G1.save, 'g.gml', fmt='?') + self.assertRaises(ValueError, G1.save, 'g.gml', backend='?') @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') def test_import_errors(self): From 966303df37285e0014b0cb0ad3ec36a1f59cf088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:13:11 +0100 Subject: [PATCH 193/365] test for missing networkx node attribute --- pygsp/tests/test_graphs.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b9114270..18aa3304 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -790,23 +790,12 @@ def test_graphtool_signal_import(self): self.assertEqual(g.signals["signal"][2], 2.4) def test_networkx_signal_import(self): - g_nx = nx.Graph() - g_nx.add_edge(3, 4) - g_nx.add_edge(2, 4) - g_nx.add_edge(3, 5) - dic_signal = { - 2: 4.0, - 3: 5.0, - 4: 3.3, - 5: 2.3 - } - - nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx) - - for i, node in enumerate(g_nx.node): - self.assertEqual(g.signals["signal1"][i], - nx.get_node_attributes(g_nx, "signal1")[node]) + graph_nx = nx.Graph() + graph_nx.add_nodes_from(range(2, 5)) + graph_nx.add_edges_from([(3, 4), (2, 4), (3, 5)]) + nx.set_node_attributes(graph_nx, {2: 4, 3: 5, 5: 2.3}, "s") + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.signals["s"], [4, 5, np.nan, 2.3]) def test_no_weights(self): From 7f17463357e0d578627dd675ab6a44efb62ed907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:19:29 +0100 Subject: [PATCH 194/365] test export of invalid signal type --- pygsp/graphs/_io.py | 8 ++++---- pygsp/tests/test_graphs.py | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 9748ea3b..77d8b709 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -229,8 +229,8 @@ def to_graphtool(self): try: dtype = convert[weights.dtype.type] except KeyError: - raise ValueError("Type {} of the edge weights is not supported." - .format(weights.dtype)) + raise TypeError("Type {} of the edge weights is not supported." + .format(weights.dtype)) prop = graph.new_edge_property(dtype) prop.get_array()[:] = weights graph.edge_properties['weight'] = prop @@ -240,8 +240,8 @@ def to_graphtool(self): try: dtype = convert[signal.dtype.type] except KeyError: - raise ValueError("Type {} of signal {} is not supported." - .format(signal.dtype, name)) + raise TypeError("Type {} of signal {} is not supported." + .format(signal.dtype, name)) prop = graph.new_vertex_property(dtype) prop.get_array()[:] = signal graph.vertex_properties[name] = prop diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 18aa3304..bc385490 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -754,6 +754,10 @@ def test_networkx_signal_export(self): for i in range(graph.N): self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) + # invalid signal type + graph = graphs.Path(3) + graph.set_signal(np.array(['a', 'b', 'c']), 'sig') + self.assertRaises(ValueError, graph.to_networkx) def test_graphtool_signal_export(self): g = graphs.Logo() @@ -767,6 +771,10 @@ def test_graphtool_signal_export(self): for i, v in enumerate(g_gt.vertices()): self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) + # invalid signal type + graph = graphs.Path(3) + graph.set_signal(np.array(['a', 'b', 'c']), 'sig') + self.assertRaises(TypeError, graph.to_graphtool) @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_signal_import(self): From 6cff27dc0504ec4e1900bf1a6092eccb59004c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:33:20 +0100 Subject: [PATCH 195/365] doc: reference graph models from networkx and graph-tool --- pygsp/graphs/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 0f746a33..49d8bdf2 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -128,6 +128,15 @@ Graph models ============ +In addition to the below graphs, useful resources are the random graph +generators from NetworkX (see `NetworkX's documentation`_) and graph-tool (see +:mod:`graph_tool.generation`), as well as graph-tool's assortment of standard +networks (see :mod:`graph_tool.collection`). +Any graph created by NetworkX or graph-tool can be imported in the PyGSP with +:meth:`Graph.from_networkx` and :meth:`Graph.from_graphtool`. + +.. _NetworkX's documentation: https://networkx.github.io/documentation/stable/reference/generators.html + .. autosummary:: Airfoil From 007f2ac79748be4feaad1b7adf69cd70c32c207d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:42:32 +0100 Subject: [PATCH 196/365] doc: See Also as numpy docstring standard --- pygsp/filters/filter.py | 8 ++++---- pygsp/graphs/_io.py | 12 ++++++------ pygsp/graphs/difference.py | 6 +++--- pygsp/graphs/graph.py | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 56d371bd..7e36f11c 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -424,7 +424,7 @@ def estimate_frame_bounds(self, x=None): B : float Upper frame bound of the filter bank. - See also + See Also -------- compute_frame: compute the frame complement: complement a filter bank to become a tight frame @@ -551,7 +551,7 @@ def compute_frame(self, **kwargs): frame : ndarray Array of size (#nodes x #filters) x #nodes. - See also + See Also -------- estimate_frame_bounds: estimate the frame bounds filter: more efficient way to filter signals @@ -619,7 +619,7 @@ def complement(self, frame_bound=None): complement: Filter The complementary filter. - See also + See Also -------- estimate_frame_bounds: estimate the frame bounds @@ -704,7 +704,7 @@ def inverse(self): The pseudo-inverse filter bank, which synthesizes (or reconstructs) a signal from its coefficients using the canonical dual frame. - See also + See Also -------- estimate_frame_bounds: estimate the frame bounds diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 77d8b709..441fcf83 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -66,7 +66,7 @@ def to_networkx(self): graph : :class:`networkx.Graph` A NetworkX graph object. - See also + See Also -------- to_graphtool : export to graph-tool save : save to a file @@ -159,7 +159,7 @@ def to_graphtool(self): graph : :class:`graph_tool.Graph` A graph-tool graph object. - See also + See Also -------- to_networkx : export to NetworkX save : save to a file @@ -284,7 +284,7 @@ def from_networkx(cls, graph, weight='weight'): If the graph is a :class:`networkx.MultiGraph`, multiedges are aggregated by summation. - See also + See Also -------- from_graphtool : import from graph-tool load : load from a file @@ -359,7 +359,7 @@ def from_graphtool(cls, graph, weight='weight'): If the graph has multiple edge connecting the same two nodes, a sum over the edges is taken to merge them. - See also + See Also -------- from_networkx : import from NetworkX load : load from a file @@ -427,7 +427,7 @@ def load(cls, path, fmt=None, backend=None): graph : :class:`Graph` The loaded graph. - See also + See Also -------- save : save a graph to a file from_networkx : load with NetworkX then import in the PyGSP @@ -505,7 +505,7 @@ def save(self, path, fmt=None, backend=None): backend : {'networkx', 'graph-tool', None}, optional Library used to load the graph. Automatically chosen if None. - See also + See Also -------- load : load a graph from a file to_networkx : export as a NetworkX graph, and save with NetworkX diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 50c57b03..7bd54ef7 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -84,7 +84,7 @@ def compute_differential_operator(self): The result is cached and accessible by the :attr:`D` property. - See also + See Also -------- grad : compute the gradient div : compute the divergence @@ -205,7 +205,7 @@ def grad(self, x): y : ndarray Gradient signal of length :attr:`n_edges` living on the edges. - See also + See Also -------- compute_differential_operator div : compute the divergence of an edge signal @@ -288,7 +288,7 @@ def div(self, y): Divergence signal of length :attr:`n_vertices` living on the vertices. - See also + See Also -------- compute_differential_operator grad : compute the gradient of a vertex signal diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index a2ade1be..f5487b14 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -653,7 +653,7 @@ def dirichlet_energy(self, x): energy : float The Dirichlet energy of the graph signal. - See also + See Also -------- grad : compute the gradient of a vertex signal From fc525e9fc709bfd7951200b133b30ade3ac8b450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:45:37 +0100 Subject: [PATCH 197/365] doc: some cross references --- pygsp/graphs/grid2d.py | 4 ++++ pygsp/graphs/nngraphs/grid2dimgpatches.py | 5 +++++ pygsp/graphs/nngraphs/imgpatches.py | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 178e89de..a3f4349c 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -17,6 +17,10 @@ class Grid2d(Graph): N2 : int Number of vertices along the second dimension (default N1). + See Also + -------- + Grid2dImgPatches + Examples -------- >>> import matplotlib.pyplot as plt diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index c619640b..34168c00 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -17,6 +17,11 @@ class Grid2dImgPatches(Graph): kwargs : dict Parameters passed to :class:`ImgPatches`. + See Also + -------- + ImgPatches + Grid2d + Examples -------- >>> import matplotlib.pyplot as plt diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 0e09f9de..df681a3b 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -23,6 +23,10 @@ class ImgPatches(NNGraph): kwargs : dict Parameters passed to :class:`NNGraph`. + See Also + -------- + Grid2dImgPatches + Notes ----- The feature vector of a pixel `i` will consist of the stacking of the From 8e5e2088bff04ec87cf439dc3062c5c5b85c1fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:55:59 +0100 Subject: [PATCH 198/365] doc: path and DCT, ring and DFT --- doc/references.bib | 5 +++-- pygsp/graphs/grid2d.py | 7 ++++++- pygsp/graphs/path.py | 34 +++++++++++++++++++++++++++++++--- pygsp/graphs/ring.py | 28 ++++++++++++++++++++++++++++ pygsp/graphs/torus.py | 14 +++++++++----- 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/doc/references.bib b/doc/references.bib index 74580d5e..de0ade43 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -77,7 +77,7 @@ @article{klein1993resistance publisher={Springer} } -@article{strang1999discrete, +@article{strang1999dct, title={The discrete cosine transform}, author={Strang, Gilbert}, journal={SIAM review}, @@ -85,7 +85,8 @@ @article{strang1999discrete number={1}, pages={135--147}, year={1999}, - publisher={SIAM} + publisher={SIAM}, + url={https://sci-hub.tw/10.1137/S0036144598336745}, } @article{shuman2013spectrum, diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index a3f4349c..afe373e7 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -10,15 +10,20 @@ class Grid2d(Graph): r"""2-dimensional grid graph. + On the 2D grid, the graph Fourier transform (GFT) is the Kronecker product + between the GFT of two :class:`~pygsp.graphs.Path` graphs. + Parameters ---------- N1 : int Number of vertices along the first dimension. N2 : int - Number of vertices along the second dimension (default N1). + Number of vertices along the second dimension. Default is ``N1``. See Also -------- + Path : 1D line with even boundary conditions + Torus : Kronecker product of two ring graphs Grid2dImgPatches Examples diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index 5018b503..94aec2c4 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -9,11 +9,31 @@ class Path(Graph): r"""Path graph. + A signal on the path graph is akin to a 1-dimensional signal in classical + signal processing. + + On the path graph, the graph Fourier transform (GFT) is the classical + discrete cosine transform (DCT_). + As the type-II DCT, the GFT assumes even boundary conditions on both sides. + + .. _DCT: https://en.wikipedia.org/wiki/Discrete_cosine_transform + Parameters ---------- N : int Number of vertices. + See Also + -------- + Ring : 1D line with periodic boundary conditions + Grid2d : Kronecker product of two path graphs + + References + ---------- + :cite:`strang1999dct` shows that each DCT basis contains the eigenvectors + of a symmetric "second difference" matrix. + They get the eight types of DCTs by varying the boundary conditions. + Examples -------- >>> import matplotlib.pyplot as plt @@ -23,9 +43,17 @@ class Path(Graph): ... _ = axes[i, 0].spy(G.W) ... _ = G.plot(ax=axes[i, 1]) - References - ---------- - See :cite:`strang1999discrete` for more informations. + The GFT of the path graph is the classical DCT. + + >>> from matplotlib import pyplot as plt + >>> n_eigenvectors = 4 + >>> graph = graphs.Path(30) + >>> fig, axes = plt.subplots(1, 2) + >>> graph.set_coordinates('line1D') + >>> graph.compute_fourier_basis() + >>> _ = graph.plot(graph.U[:, :n_eigenvectors], ax=axes[0]) + >>> _ = axes[0].legend(range(n_eigenvectors)) + >>> _ = axes[1].plot(graph.e, '.') """ diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index af57f3be..e9da9b4c 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -9,6 +9,17 @@ class Ring(Graph): r"""K-regular ring graph. + A signal on the ring graph is akin to a 1-dimensional periodic signal in + classical signal processing. + + On the ring graph, the graph Fourier transform (GFT) is the classical + discrete Fourier transform (DFT_). + Actually, the Laplacian of the ring graph is a `circulant matrix`_, and any + circulant matrix is diagonalized by the DFT. + + .. _DFT: https://en.wikipedia.org/wiki/Discrete_Fourier_transform + .. _circulant matrix: https://en.wikipedia.org/wiki/Circulant_matrix + Parameters ---------- N : int @@ -16,6 +27,11 @@ class Ring(Graph): k : int Number of neighbors in each direction. + See Also + -------- + Path : 1D line with even boundary conditions + Torus : Kronecker product of two ring graphs + Examples -------- >>> import matplotlib.pyplot as plt @@ -24,6 +40,18 @@ class Ring(Graph): >>> _ = axes[0].spy(G.W) >>> _ = G.plot(ax=axes[1]) + The GFT of the ring graph is the classical DFT. + + >>> from matplotlib import pyplot as plt + >>> n_eigenvectors = 4 + >>> graph = graphs.Ring(30) + >>> fig, axes = plt.subplots(1, 2) + >>> graph.set_coordinates('line1D') + >>> graph.compute_fourier_basis() + >>> _ = graph.plot(graph.U[:, :n_eigenvectors], ax=axes[0]) + >>> _ = axes[0].legend(range(n_eigenvectors)) + >>> _ = axes[1].plot(graph.e, '.') + """ def __init__(self, N=64, k=1, **kwargs): diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index f28a8141..1124b7a9 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -9,16 +9,20 @@ class Torus(Graph): r"""Sampled torus manifold. + On the torus, the graph Fourier transform (GFT) is the Kronecker product + between the GFT of two :class:`~pygsp.graphs.Ring` graphs. + Parameters ---------- Nv : int - Number of vertices along the first dimension (default is 16) + Number of vertices along the first dimension. Mv : int - Number of vertices along the second dimension (default is Nv) + Number of vertices along the second dimension. Default is ``Nv``. - References - ---------- - See :cite:`strang1999discrete` for more informations. + See Also + -------- + Ring : 1D line with periodic boundary conditions + Grid2d : Kronecker product of two path graphs Examples -------- From fb5cc2279a97151effb7fdf907a9b5e033f5eb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Mar 2019 01:18:37 +0100 Subject: [PATCH 199/365] doc: graph formats and manipulation + visualization software --- pygsp/graphs/_io.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 441fcf83..f44cefd1 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -495,6 +495,34 @@ def save(self, path, fmt=None, backend=None): `N`-dimensional signals are broken into `N` 1-dimensional signals. They will eventually be joined back together on import. + Supported formats are: + + * GraphML_, a comprehensive XML format. + `Wikipedia `_. + Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, + Cytoscape_, SocNetV_. + * GML_ (Graph Modelling Language), a simple non-XML format. + `Wikipedia `_. + Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, + Cytoscape_, SocNetV_, Tulip_. + * GEXF_ (Graph Exchange XML Format), Gephi's XML format. + Supported by NetworkX_, NetworKit_, Gephi_, Tulip_, ngraph_. + + If unsure, we recommend GraphML_. + + .. _GraphML: http://graphml.graphdrawing.org + .. _GML: http://www.infosun.fim.uni-passau.de/Graphlet/GML/gml-tr.html + .. _GEXF: https://gephi.org/gexf/format + .. _NetworkX: https://networkx.github.io + .. _graph-tool: https://graph-tool.skewed.de + .. _NetworKit: https://networkit.github.io + .. _igraph: https://igraph.org + .. _ngraph: https://github.com/anvaka/ngraph + .. _Gephi: https://gephi.org + .. _Cytoscape: https://cytoscape.org + .. _SocNetV: https://socnetv.org + .. _Tulip: http://tulip.labri.fr + Parameters ---------- path : string From 82368f3ec00043cb7ff19a2468021e37bcb893bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Mar 2019 03:12:17 +0100 Subject: [PATCH 200/365] to_networkx: convert numpy int to python int nx only properly deals with python objects --- pygsp/graphs/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index f44cefd1..3ac774c1 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -128,7 +128,7 @@ def convert(number): def edges(): for source, target, weight in zip(*self.get_edge_list()): - yield source, target, {'weight': convert(weight)} + yield int(source), int(target), {'weight': convert(weight)} def nodes(): for vertex in range(self.n_vertices): From 32c888b377672034b81ea269173b71cef946a574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Mar 2019 23:05:42 +0100 Subject: [PATCH 201/365] subgraph: sub-sample signals as well --- pygsp/graphs/graph.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index f5487b14..d5a72c12 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -252,7 +252,8 @@ def subgraph(self, vertices): Parameters ---------- vertices : list - List of vertices to keep. + Vertices to keep. + Either a list of indices or an indicator function. Returns ------- @@ -279,7 +280,10 @@ def subgraph(self, vertices): coords = self.coords[vertices] except AttributeError: coords = None - return Graph(adjacency, self.lap_type, coords, self.plotting) + graph = Graph(adjacency, self.lap_type, coords, self.plotting) + for name, signal in self.signals.items(): + graph.set_signal(signal[vertices], name) + return graph def is_connected(self): r"""Check if the graph is connected (cached). From 4d2f3bcac8c7b3864402616165d377b635e464ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Mar 2019 12:14:17 +0100 Subject: [PATCH 202/365] readme: update binder badge --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 1209c76f..d1caf309 100644 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ A (mostly unmaintained) `Matlab version `_ exists. :target: https://coveralls.io/github/epfl-lts2/pygsp .. |github| image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp -.. |binder| image:: https://mybinder.org/badge.svg +.. |binder| image:: https://mybinder.org/badge_logo.svg :target: https://mybinder.org/v2/gh/epfl-lts2/pygsp/master?filepath=playground.ipynb .. |conda| image:: https://anaconda.org/conda-forge/pygsp/badges/installer/conda.svg :target: https://anaconda.org/conda-forge/pygsp From d4ce59eed2078589ed5daf2002944e857d9ee3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Mar 2019 22:18:20 +0100 Subject: [PATCH 203/365] doc: Reference guide => API reference --- doc/reference/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/reference/index.rst b/doc/reference/index.rst index 4e58903d..71422b6b 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -1,6 +1,6 @@ -=============== -Reference guide -=============== +============= +API reference +============= .. automodule:: pygsp From e1728ae7bc7862bd8b0a1ba3e176c424a96eb27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Mar 2019 22:43:56 +0100 Subject: [PATCH 204/365] doc: use sphinx-gallery for short examples --- .gitignore | 3 ++- doc/conf.py | 23 ++++++++++++++++++----- doc/index.rst | 1 + examples/README.txt | 3 +++ setup.py | 2 ++ 5 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 examples/README.txt diff --git a/.gitignore b/.gitignore index c6c6ba6d..f56c8f74 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,8 @@ output/*.html output/*/index.html # Sphinx documentation -doc/_build +/doc/_build +/doc/examples/ # Vim swap files .*.swp diff --git a/doc/conf.py b/doc/conf.py index 37865e04..25f6d73d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -2,11 +2,13 @@ import pygsp -extensions = ['sphinx.ext.viewcode', - 'sphinx.ext.autosummary', - 'sphinx.ext.mathjax', - 'sphinx.ext.inheritance_diagram', - 'sphinxcontrib.bibtex'] +extensions = [ + 'sphinx.ext.viewcode', + 'sphinx.ext.autosummary', + 'sphinx.ext.mathjax', + 'sphinx.ext.inheritance_diagram', + 'sphinxcontrib.bibtex', +] extensions.append('sphinx.ext.autodoc') autodoc_default_flags = ['members', 'undoc-members'] @@ -37,6 +39,17 @@ from pygsp import graphs, filters, utils, plotting """ +extensions.append('sphinx_gallery.gen_gallery') +sphinx_gallery_conf = { + 'examples_dirs': '../examples', + 'gallery_dirs': 'examples', + 'filename_pattern': '/', + 'reference_url': { + 'pygsp': None, + }, + 'show_memory': True, +} + exclude_patterns = ['_build'] source_suffix = '.rst' master_doc = 'index' diff --git a/doc/index.rst b/doc/index.rst index 4fab6d08..1ebdcf59 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -5,6 +5,7 @@ Home tutorials/index + examples/index reference/index contributing history diff --git a/examples/README.txt b/examples/README.txt new file mode 100644 index 00000000..b90c0e1c --- /dev/null +++ b/examples/README.txt @@ -0,0 +1,3 @@ +======== +Examples +======== diff --git a/setup.py b/setup.py index c8b360de..37849940 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,8 @@ 'sphinx', 'numpydoc', 'sphinxcontrib-bibtex', + 'sphinx-gallery', + 'memory_profiler', 'sphinx-rtd-theme', # Build and upload packages. 'wheel', From f345e9d6fedeb94f80c31370971f8004cd14f976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Mar 2019 23:12:34 +0100 Subject: [PATCH 205/365] doc: eigenvalue concentration example --- examples/eigenvalue_concentration.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 examples/eigenvalue_concentration.py diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py new file mode 100644 index 00000000..d9fcdd86 --- /dev/null +++ b/examples/eigenvalue_concentration.py @@ -0,0 +1,26 @@ +r""" +Concentration of the eigenvalues +================================ + +The eigenvalues of the graph Laplacian concentrates to the same value as the +graph becomes full. +""" + +from matplotlib import pyplot as plt +import pygsp as pg + +n_neighbors = [1, 2, 5, 8] +fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) + +for k, ax in zip(n_neighbors, axes.T): + graph = pg.graphs.Ring(17, k=k) + graph.compute_fourier_basis() + graph.plot(graph.U[:, 1], ax=ax[0]) + ax[0].axis('equal') + ax[1].spy(graph.W) + ax[2].plot(graph.e, '.') + ax[2].set_title('k={}'.format(k)) + graph.set_coordinates('line1D') + graph.plot(graph.U[:, :4], ax=ax[3], title='') + +fig.tight_layout() From 7aeab8095bc1e526d6c2e00b0f14b10aeb5b9dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:01:34 +0100 Subject: [PATCH 206/365] doc gallery: need this config for intersphinx links to work see https://github.com/sphinx-gallery/sphinx-gallery/issues/467 --- .gitignore | 3 ++- doc/conf.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index f56c8f74..9c15768f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,9 @@ output/*.html output/*/index.html # Sphinx documentation -/doc/_build +/doc/_build/ /doc/examples/ +/doc/backrefs/ # Vim swap files .*.swp diff --git a/doc/conf.py b/doc/conf.py index 25f6d73d..569c1f04 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -44,9 +44,9 @@ 'examples_dirs': '../examples', 'gallery_dirs': 'examples', 'filename_pattern': '/', - 'reference_url': { - 'pygsp': None, - }, + 'reference_url': {'pygsp': None}, + 'backreferences_dir': 'backrefs', + 'doc_module': 'pygsp', 'show_memory': True, } From 1e8615cafd3501548471b911bab59d71a9b84032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:13:06 +0100 Subject: [PATCH 207/365] doc: more examples from my talks --- examples/filtering.py | 70 ++++++++++++++++++++++++++++++++ examples/fourier_basis.py | 46 +++++++++++++++++++++ examples/fourier_transform.py | 50 +++++++++++++++++++++++ examples/heat_diffusion.py | 49 +++++++++++++++++++++++ examples/kernel_localization.py | 44 ++++++++++++++++++++ examples/random_walk.py | 71 +++++++++++++++++++++++++++++++++ examples/wave_propagation.py | 49 +++++++++++++++++++++++ 7 files changed, 379 insertions(+) create mode 100644 examples/filtering.py create mode 100644 examples/fourier_basis.py create mode 100644 examples/fourier_transform.py create mode 100644 examples/heat_diffusion.py create mode 100644 examples/kernel_localization.py create mode 100644 examples/random_walk.py create mode 100644 examples/wave_propagation.py diff --git a/examples/filtering.py b/examples/filtering.py new file mode 100644 index 00000000..d53e388f --- /dev/null +++ b/examples/filtering.py @@ -0,0 +1,70 @@ +r""" +Filtering a graph signal +======================== + +A graph signal is filtered by transforming it to the spectral domain (via the +Fourier transform), performing a point-wise multiplication (motivated by the +convolution theorem), and transforming it back to the vertex domain (via the +inverse graph Fourier transform). + +.. note:: + + In practice, filtering is implemented in the vertex domain to avoid the + computationally expensive graph Fourier transform. To do so, filters are + implemented as polynomials of the eigenvalues / Laplacian. Hence, filtering + a signal reduces to its multiplications with sparse matrices (the graph + Laplacian). + +""" + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +G = pg.graphs.Sensor(seed=42) +G.compute_fourier_basis() + +#g = pg.filters.Rectangular(G, band_max=0.2) +g = pg.filters.Expwin(G, band_max=0.5) + +fig, axes = plt.subplots(1, 3, figsize=(12, 4)) +fig.subplots_adjust(hspace=0.5) + +x = np.random.RandomState(1).normal(size=G.N) +#x = np.random.RandomState(42).uniform(-1, 1, size=G.N) +x = 3 * x / np.linalg.norm(x) +y = g.filter(x) +x_hat = G.gft(x).squeeze() +y_hat = G.gft(y).squeeze() + +limits = [x.min(), x.max()] + +G.plot(x, limits=limits, ax=axes[0], title='input signal $x$ in the vertex domain') +axes[0].text(0, -0.1, '$x^T L x = {:.2f}$'.format(G.dirichlet_energy(x))) +axes[0].set_axis_off() + +g.plot(ax=axes[1], alpha=1) +line_filt = axes[1].lines[-2] +line_in, = axes[1].plot(G.e, np.abs(x_hat), '.-') +line_out, = axes[1].plot(G.e, np.abs(y_hat), '.-') +#axes[1].set_xticks(range(0, 16, 4)) +axes[1].set_xlabel(r'graph frequency $\lambda$') +axes[1].set_ylabel(r'frequency content $\hat{x}(\lambda)$') +axes[1].set_title(r'signals in the spectral domain') +axes[1].legend(['input signal $\hat{x}$']) +labels = [ + r'input signal $\hat{x}$', + 'kernel $g$', + r'filtered signal $\hat{y}$', +] +axes[1].legend([line_in, line_filt, line_out], labels, loc='upper right') + +G.plot(y, limits=limits, ax=axes[2], title='filtered signal $y$ in the vertex domain') +axes[2].text(0, -0.1, '$y^T L y = {:.2f}$'.format(G.dirichlet_energy(y))) +axes[2].set_axis_off() + +fig.tight_layout() diff --git a/examples/fourier_basis.py b/examples/fourier_basis.py new file mode 100644 index 00000000..e99126ad --- /dev/null +++ b/examples/fourier_basis.py @@ -0,0 +1,46 @@ +r""" +Fourier basis of graphs +======================= + +The eigenvectors of the graph Laplacian form the Fourier basis. +The eigenvalues are a measure of variation of their corresponding eigenvector. +The lower the eigenvalue, the smoother the eigenvector. They are hence a +measure of "frequency". + +In classical signal processing, Fourier modes are completely delocalized, like +on the grid graph. For general graphs however, Fourier modes might be +localized. See :attr:`pygsp.graphs.Graph.coherence`. +""" + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +n_eigenvectors = 7 + +fig, axes = plt.subplots(2, 7, figsize=(15, 4)) + +def plot_eigenvectors(G, axes): + G.compute_fourier_basis(n_eigenvectors) + limits = [f(G.U) for f in (np.min, np.max)] + for i, ax in enumerate(axes): + G.plot(G.U[:, i], limits=limits, colorbar=False, vertex_size=50, ax=ax) + energy = abs(G.dirichlet_energy(G.U[:, i])) + ax.set_title(r'$u_{0}^\top L u_{0} = {1:.2f}$'.format(i+1, energy)) + ax.set_axis_off() + +G = pg.graphs.Grid2d(10, 10) +plot_eigenvectors(G, axes[0]) +fig.subplots_adjust(hspace=0.5, right=0.8) +cax = fig.add_axes([0.82, 0.60, 0.01, 0.26]) +fig.colorbar(axes[0, -1].collections[1], cax=cax, ticks=[-0.2, 0, 0.2]) + +G = pg.graphs.Sensor(seed=42) +plot_eigenvectors(G, axes[1]) +fig.subplots_adjust(hspace=0.5, right=0.8) +cax = fig.add_axes([0.82, 0.16, 0.01, 0.26]) +fig.colorbar(axes[1, -1].collections[1], cax=cax, ticks=[-0.4, 0, 0.4]) diff --git a/examples/fourier_transform.py b/examples/fourier_transform.py new file mode 100644 index 00000000..8e99969c --- /dev/null +++ b/examples/fourier_transform.py @@ -0,0 +1,50 @@ +r""" +Fourier transform +================= + +The graph Fourier transform (:meth:`pygsp.graphs.Graph.gft`) transforms a +signal from the vertex domain to the spectral domain. The smoother the signal +(see :meth:`pygsp.graphs.Graph.dirichlet_energy`), the lower in the frequencies +its energy is concentrated. +""" + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +G = pg.graphs.Sensor(seed=42) +G.compute_fourier_basis() + +scales = [10, 3, 0] +limit = 0.32 + +fig, axes = plt.subplots(2, len(scales), figsize=(12, 4)) +fig.subplots_adjust(hspace=0.5) + +x0 = np.random.RandomState(1).normal(size=G.N) +for i, scale in enumerate(scales): + g = pg.filters.Heat(G, scale) + x = g.filter(x0).squeeze() + x /= np.linalg.norm(x) + x_hat = G.gft(x).squeeze() + + assert np.all((-limit < x) & (x < limit)) + G.plot(x, limits=[-limit, limit], ax=axes[0, i]) + axes[0, i].set_axis_off() + axes[0, i].set_title('$x^T L x = {:.2f}$'.format(G.dirichlet_energy(x))) + + axes[1, i].plot(G.e, np.abs(x_hat), '.-') + axes[1, i].set_xticks(range(0, 16, 4)) + axes[1, i].set_xlabel(r'graph frequency $\lambda$') + axes[1, i].set_ylim(-0.05, 0.95) + +axes[1, 0].set_ylabel(r'frequency content $\hat{x}(\lambda)$') + +# axes[0, 0].set_title(r'$x$: signal in the vertex domain') +# axes[1, 0].set_title(r'$\hat{x}$: signal in the spectral domain') + +fig.tight_layout() diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py new file mode 100644 index 00000000..8ea42608 --- /dev/null +++ b/examples/heat_diffusion.py @@ -0,0 +1,49 @@ +r""" +Heat diffusion on graphs +======================== + +Solve the heat equation by filtering the initial conditions with the heat +kernel. +""" + +from os import path + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +n_side = 13 +G = pg.graphs.Grid2d(n_side) +G.compute_fourier_basis() + +sources = [ + (n_side//4 * n_side) + (n_side//4), + (n_side*3//4 * n_side) + (n_side*3//4), +] +x = np.zeros(G.n_vertices) +x[sources] = 5 + +times = [0, 5, 10, 20] + +fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) +for i, t in enumerate(times): + g = pg.filters.Heat(G, scale=t) + title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + g.plot(alpha=1, ax=axes[0, i], title=title) + axes[0, i].set_xlabel(r'$\lambda$') +# axes[0, i].set_ylabel(r'$g(\lambda)$') + if i > 0: + axes[0, i].set_ylabel('') + y = g.filter(x) + line, = axes[0, i].plot(G.e, G.gft(y)) + labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + axes[1, i].set_aspect('equal', 'box') + axes[1, i].set_axis_off() + +fig.tight_layout() diff --git a/examples/kernel_localization.py b/examples/kernel_localization.py new file mode 100644 index 00000000..5bbd2225 --- /dev/null +++ b/examples/kernel_localization.py @@ -0,0 +1,44 @@ +r""" +Kernel localization +=================== + +In classical signal processing, a filter can be translated in the vertex +domain. We cannot do that on graphs. Instead, we can +:meth:`~pygsp.filters.Filter.localize` a filter kernel. Note how on classic +structures (like the ring), the localized kernel is the same everywhere, while +it changes when localized on irregular graphs. +""" + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +fig, axes = plt.subplots(2, 4, figsize=(10, 4)) + +graphs = [ + pg.graphs.Ring(40), + pg.graphs.Sensor(64, seed=42), +] + +locations = [0, 10, 20] + +for graph, axs in zip(graphs, axes): + graph.compute_fourier_basis() + g = pg.filters.Heat(graph) + g.plot(ax=axs[0], title='heat kernel') + axs[0].set_xlabel(r'eigenvalues $\lambda$') + axs[0].set_ylabel(r'$g(\lambda) = \exp \left( \frac{{-{}\lambda}}{{\lambda_{{max}}}} \right)$'.format(g.scale[0])) + maximum = 0 + for loc in locations: + x = g.localize(loc) + maximum = np.maximum(maximum, x.max()) + for loc, ax in zip(locations, axs[1:]): + graph.plot(g.localize(loc), limits=[0, maximum], highlight=loc, ax=ax, + title=r'$g(L) \delta_{{{}}}$'.format(loc)) + ax.set_axis_off() + +fig.tight_layout() diff --git a/examples/random_walk.py b/examples/random_walk.py new file mode 100644 index 00000000..b3031b4c --- /dev/null +++ b/examples/random_walk.py @@ -0,0 +1,71 @@ +r""" +Random walks +============ + +Probability of a random walker to be on any given vertex after a given number +of steps starting from a given distribution. +""" + +# sphinx_gallery_thumbnail_number = 2 + +import numpy as np +from scipy import sparse +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +N = 7 +steps = [0, 1, 2, 3] + +graph = pg.graphs.Grid2d(N) +delta = np.zeros(graph.N) +delta[N//2*N + N//2] = 1 + +probability = sparse.diags(graph.dw**(-1)) @ graph.W + +fig, axes = plt.subplots(1, len(steps), figsize=(12, 3)) +for step, ax in zip(steps, axes): + state = delta @ probability**step + graph.plot(state, ax=ax, title=r'$\delta P^{}$'.format(step)) + ax.set_axis_off() + +fig.tight_layout() + +############################################################################### +# Stationary distribution. + +graphs = [ + pg.graphs.Ring(10), + pg.graphs.Grid2d(5), + pg.graphs.Comet(8, 4), + pg.graphs.BarabasiAlbert(20, seed=42), +] + +fig, axes = plt.subplots(1, len(graphs), figsize=(12, 3)) + +for graph, ax in zip(graphs, axes): + + if not hasattr(graph, 'coords'): + graph.set_coordinates(seed=10) + + P = sparse.diags(graph.dw**(-1)) @ graph.W + +# e, u = np.linalg.eig(P.T.toarray()) +# np.testing.assert_allclose(np.linalg.inv(u.T) @ np.diag(e) @ u.T, +# P.toarray(), atol=1e-10) +# np.testing.assert_allclose(np.abs(e[0]), 1) +# stationary = np.abs(u.T[0]) + + e, u = sparse.linalg.eigs(P.T, k=1, which='LR') + np.testing.assert_allclose(e, 1) + stationary = np.abs(u).squeeze() + assert np.all(stationary < 0.71) + + colorbar = False if type(graph) is pg.graphs.Ring else True + graph.plot(stationary, colorbar=colorbar, ax=ax, title='$xP = x$') + ax.set_axis_off() + +fig.tight_layout() diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py new file mode 100644 index 00000000..2abf71d9 --- /dev/null +++ b/examples/wave_propagation.py @@ -0,0 +1,49 @@ +r""" +Wave propagation on graphs +========================== + +Solve the wave equation by filtering the initial conditions with the wave +kernel. +""" + +from os import path + +import numpy as np +from matplotlib import pyplot as plt +import pygsp as pg + +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + +n_side = 13 +G = pg.graphs.Grid2d(n_side) +G.compute_fourier_basis() + +sources = [ + (n_side//4 * n_side) + (n_side//4), + (n_side*3//4 * n_side) + (n_side*3//4), +] +x = np.zeros(G.n_vertices) +x[sources] = 5 + +times = [0, 5, 10, 20] + +fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) +for i, t in enumerate(times): + g = pg.filters.Wave(G, time=t, speed=1) + title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + g.plot(alpha=1, ax=axes[0, i], title=title) + axes[0, i].set_xlabel(r'$\lambda$') +# axes[0, i].set_ylabel(r'$g(\lambda)$') + if i > 0: + axes[0, i].set_ylabel('') + y = g.filter(x) + line, = axes[0, i].plot(G.e, G.gft(y)) + labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + axes[1, i].set_aspect('equal', 'box') + axes[1, i].set_axis_off() + +fig.tight_layout() From eaa8878e3808580d74190bc922c4b98ce9516526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:25:21 +0100 Subject: [PATCH 208/365] history: examples gallery --- doc/history.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/history.rst b/doc/history.rst index 1bc7840d..a3cc366f 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -92,7 +92,7 @@ The following packages were made optional dependencies: workflow, it's not necessary for users who only want to process data without plotting graphs, signals and filters. * pyflann, as it is only used for approximate kNN. The problem was that the - source distribution would not build for Windows. On conda-forge, (py)flann + source distribution would not build for Windows. On conda-forge, (py)flann is not built for Windows either. Moreover, matplotlib is now the default drawing backend. It's well integrated From 27f8809487101fc4a2486f46e6c7eb7de8b12185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:29:15 +0100 Subject: [PATCH 209/365] examples: while math looks better (especially for papers and slides), latex is not available everywhere --- examples/filtering.py | 4 ---- examples/fourier_basis.py | 4 ---- examples/fourier_transform.py | 4 ---- examples/heat_diffusion.py | 4 ---- examples/kernel_localization.py | 4 ---- examples/random_walk.py | 4 ---- examples/wave_propagation.py | 4 ---- 7 files changed, 28 deletions(-) diff --git a/examples/filtering.py b/examples/filtering.py index d53e388f..62256685 100644 --- a/examples/filtering.py +++ b/examples/filtering.py @@ -21,10 +21,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/fourier_basis.py b/examples/fourier_basis.py index e99126ad..1064c58f 100644 --- a/examples/fourier_basis.py +++ b/examples/fourier_basis.py @@ -16,10 +16,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_eigenvectors = 7 fig, axes = plt.subplots(2, 7, figsize=(15, 4)) diff --git a/examples/fourier_transform.py b/examples/fourier_transform.py index 8e99969c..2589ef12 100644 --- a/examples/fourier_transform.py +++ b/examples/fourier_transform.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py index 8ea42608..e0e0ac8d 100644 --- a/examples/heat_diffusion.py +++ b/examples/heat_diffusion.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() diff --git a/examples/kernel_localization.py b/examples/kernel_localization.py index 5bbd2225..d9484fa9 100644 --- a/examples/kernel_localization.py +++ b/examples/kernel_localization.py @@ -13,10 +13,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - fig, axes = plt.subplots(2, 4, figsize=(10, 4)) graphs = [ diff --git a/examples/random_walk.py b/examples/random_walk.py index b3031b4c..47e67dd3 100644 --- a/examples/random_walk.py +++ b/examples/random_walk.py @@ -13,10 +13,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - N = 7 steps = [0, 1, 2, 3] diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py index 2abf71d9..fc6f8766 100644 --- a/examples/wave_propagation.py +++ b/examples/wave_propagation.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() From 27c13631257041112ca96cd3651d95a1a7b5823b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 02:26:55 +0100 Subject: [PATCH 210/365] replace f-strings (>=3.6) and @ infix operator (>=3.5) --- examples/heat_diffusion.py | 6 +++--- examples/random_walk.py | 6 +++--- examples/wave_propagation.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py index e0e0ac8d..81701bbe 100644 --- a/examples/heat_diffusion.py +++ b/examples/heat_diffusion.py @@ -28,7 +28,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Heat(G, scale=t) - title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +36,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() diff --git a/examples/random_walk.py b/examples/random_walk.py index 47e67dd3..9fc69456 100644 --- a/examples/random_walk.py +++ b/examples/random_walk.py @@ -20,11 +20,11 @@ delta = np.zeros(graph.N) delta[N//2*N + N//2] = 1 -probability = sparse.diags(graph.dw**(-1)) @ graph.W +probability = sparse.diags(graph.dw**(-1)).dot(graph.W) fig, axes = plt.subplots(1, len(steps), figsize=(12, 3)) for step, ax in zip(steps, axes): - state = delta @ probability**step + state = (probability**step).__rmatmul__(delta) ## = delta @ probability**step graph.plot(state, ax=ax, title=r'$\delta P^{}$'.format(step)) ax.set_axis_off() @@ -47,7 +47,7 @@ if not hasattr(graph, 'coords'): graph.set_coordinates(seed=10) - P = sparse.diags(graph.dw**(-1)) @ graph.W + P = sparse.diags(graph.dw**(-1)).dot(graph.W) # e, u = np.linalg.eig(P.T.toarray()) # np.testing.assert_allclose(np.linalg.inv(u.T) @ np.diag(e) @ u.T, diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py index fc6f8766..ec69b73c 100644 --- a/examples/wave_propagation.py +++ b/examples/wave_propagation.py @@ -28,7 +28,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Wave(G, time=t, speed=1) - title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +36,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() From 804b5a578b4ab10c05781be14dfcfe5f352527bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 03:11:19 +0100 Subject: [PATCH 211/365] =?UTF-8?q?ring=20example:=20check=20that=20DFT=20?= =?UTF-8?q?is=20an=20eigenbasis=20(by=20Nathana=C3=ABl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/eigenvalue_concentration.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py index d9fcdd86..e47c78cf 100644 --- a/examples/eigenvalue_concentration.py +++ b/examples/eigenvalue_concentration.py @@ -6,6 +6,7 @@ graph becomes full. """ +import numpy as np from matplotlib import pyplot as plt import pygsp as pg @@ -13,6 +14,7 @@ fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) for k, ax in zip(n_neighbors, axes.T): + graph = pg.graphs.Ring(17, k=k) graph.compute_fourier_basis() graph.plot(graph.U[:, 1], ax=ax[0]) @@ -23,4 +25,14 @@ graph.set_coordinates('line1D') graph.plot(graph.U[:, :4], ax=ax[3], title='') + # Check that the DFT matrix is an eigenbasis of the Laplacian. + U = np.fft.fft(np.identity(graph.n_vertices)) + LambdaM = (graph.L.todense().dot(U)) / (U + 1e-15) + # Eigenvalues should be real. + assert np.all(np.abs(np.imag(LambdaM)) < 1e-10) + LambdaM = np.real(LambdaM) + # Check that the eigenvectors are really eigenvectors of the laplacian. + Lambda = np.mean(LambdaM, axis=0) + assert np.all(np.abs(LambdaM - Lambda) < 1e-10) + fig.tight_layout() From fc195b0fe0cc400417b40c8958faaa6896743b80 Mon Sep 17 00:00:00 2001 From: nperraud <6399466+nperraud@users.noreply.github.com> Date: Fri, 29 Mar 2019 11:42:19 +0100 Subject: [PATCH 212/365] Remove eigenvectors from concentration demo --- examples/eigenvalue_concentration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py index e47c78cf..c372bf2a 100644 --- a/examples/eigenvalue_concentration.py +++ b/examples/eigenvalue_concentration.py @@ -11,7 +11,7 @@ import pygsp as pg n_neighbors = [1, 2, 5, 8] -fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) +fig, axes = plt.subplots(3, len(n_neighbors), figsize=(15, 8)) for k, ax in zip(n_neighbors, axes.T): @@ -22,8 +22,8 @@ ax[1].spy(graph.W) ax[2].plot(graph.e, '.') ax[2].set_title('k={}'.format(k)) - graph.set_coordinates('line1D') - graph.plot(graph.U[:, :4], ax=ax[3], title='') + #graph.set_coordinates('line1D') + #graph.plot(graph.U[:, :4], ax=ax[3], title='') # Check that the DFT matrix is an eigenbasis of the Laplacian. U = np.fft.fft(np.identity(graph.n_vertices)) From 83c22907a1d7585ac1af0edba871accfc0c11405 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 11:33:23 +0200 Subject: [PATCH 213/365] Add diagonals to grid2d --- pygsp/graphs/grid2d.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index afe373e7..37e4142f 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,6 +19,8 @@ class Grid2d(Graph): Number of vertices along the first dimension. N2 : int Number of vertices along the second dimension. Default is ``N1``. + diag_value : float + Value of the diagnal edges. Default is ``0.0`` See Also -------- @@ -36,7 +38,7 @@ class Grid2d(Graph): """ - def __init__(self, N1=16, N2=None, **kwargs): + def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): if N2 is None: N2 = N1 @@ -51,8 +53,15 @@ def __init__(self, N1=16, N2=None, **kwargs): diag_1 = np.ones(N - 1) diag_1[(N2 - 1)::N2] = 0 diag_2 = np.ones(N - N2) - W = sparse.diags(diagonals=[diag_1, diag_2], - offsets=[-1, -N2], + + # Connecting node with they diagonal neighbours + diag_3 = np.full(N - N2 - 1, diag_value) + diag_4 = np.full(N - 2, diag_value) + diag_3[N2 - 1::N2] = 0 + diag_4[0::N2] = 0 + + W = sparse.diags(diagonals=[diag_1, diag_2, diag_3, diag_4], + offsets=[-1, -N2, -N2 - 1, -N2 + 1], shape=(N, N), format='csr', dtype='float') From d7da209c38305c2938cffe2084228f8d2ae82f2f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 11:45:44 +0200 Subject: [PATCH 214/365] Add test for the diagonal values --- pygsp/tests/test_graphs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index bc385490..969e8dad 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -665,6 +665,16 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) + def test_grid2d_diagonals(self): + value = 0.5 + G = graphs.Grid2d(6, 7, diag_value=value) + self.assertEqual(G.W[2, 8], value) + self.assertEqual(G.W[9, 1], value) + self.assertEqual(G.W[9, 3], value) + self.assertEqual(G.W[2, 14], 0.0) + self.assertEqual(G.W[17, 1], 0.0) + self.assertEqual(G.W[9, 16], 1.0) + self.assertEqual(G.W[20, 27], 1.0) suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 09b41b4eb30688d569db7473bf1fc911156a385b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 12:38:43 +0200 Subject: [PATCH 215/365] fix bug with small graphs --- pygsp/graphs/grid2d.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 37e4142f..58ad0c0f 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -54,17 +54,25 @@ def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): diag_1[(N2 - 1)::N2] = 0 diag_2 = np.ones(N - N2) - # Connecting node with they diagonal neighbours - diag_3 = np.full(N - N2 - 1, diag_value) - diag_4 = np.full(N - 2, diag_value) - diag_3[N2 - 1::N2] = 0 - diag_4[0::N2] = 0 - - W = sparse.diags(diagonals=[diag_1, diag_2, diag_3, diag_4], - offsets=[-1, -N2, -N2 - 1, -N2 + 1], + W = sparse.diags(diagonals=[diag_1, diag_2], + offsets=[-1, -N2], shape=(N, N), format='csr', dtype='float') + + if min(N1, N2) > 1 and diag_value != 0.0: + # Connecting node with they diagonal neighbours + diag_3 = np.full(N - N2 - 1, diag_value) + diag_4 = np.full(N - N2 + 1, diag_value) + diag_3[N2 - 1::N2] = 0 + diag_4[0::N2] = 0 + D = sparse.diags(diagonals=[diag_3, diag_4], + offsets=[-N2 - 1, -N2 + 1], + shape=(N, N), + format='csr', + dtype='float') + W += D + W = utils.symmetrize(W, method='tril') x = np.kron(np.ones((N1, 1)), (np.arange(N2)/float(N2)).reshape(N2, 1)) From 15606555f4a7264dacabcdb809002f304ddab49f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 12:39:36 +0200 Subject: [PATCH 216/365] style --- pygsp/graphs/grid2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 58ad0c0f..c4d83642 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -72,7 +72,7 @@ def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): format='csr', dtype='float') W += D - + W = utils.symmetrize(W, method='tril') x = np.kron(np.ones((N1, 1)), (np.arange(N2)/float(N2)).reshape(N2, 1)) From 2dce0cbebdf9043be901515ef00bcc31dc08725b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 15:00:04 +0200 Subject: [PATCH 217/365] renaming --- pygsp/graphs/grid2d.py | 8 ++++---- pygsp/tests/test_graphs.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index c4d83642..b2f23c70 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -38,7 +38,7 @@ class Grid2d(Graph): """ - def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): + def __init__(self, N1=16, N2=None, diagonal=0.0, **kwargs): if N2 is None: N2 = N1 @@ -60,10 +60,10 @@ def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): format='csr', dtype='float') - if min(N1, N2) > 1 and diag_value != 0.0: + if min(N1, N2) > 1 and diagonal != 0.0: # Connecting node with they diagonal neighbours - diag_3 = np.full(N - N2 - 1, diag_value) - diag_4 = np.full(N - N2 + 1, diag_value) + diag_3 = np.full(N - N2 - 1, diagonal) + diag_4 = np.full(N - N2 + 1, diagonal) diag_3[N2 - 1::N2] = 0 diag_4[0::N2] = 0 D = sparse.diags(diagonals=[diag_3, diag_4], diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 969e8dad..44d7be9c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -667,7 +667,7 @@ def test_grid2dimgpatches(self): def test_grid2d_diagonals(self): value = 0.5 - G = graphs.Grid2d(6, 7, diag_value=value) + G = graphs.Grid2d(6, 7, diagonal=value) self.assertEqual(G.W[2, 8], value) self.assertEqual(G.W[9, 1], value) self.assertEqual(G.W[9, 3], value) From b2eb1f338bbc223708f3ec929ebaa87a00f5d667 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 15:00:50 +0200 Subject: [PATCH 218/365] rename doc --- pygsp/graphs/grid2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index b2f23c70..26bef79b 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,7 +19,7 @@ class Grid2d(Graph): Number of vertices along the first dimension. N2 : int Number of vertices along the second dimension. Default is ``N1``. - diag_value : float + diagonal : float Value of the diagnal edges. Default is ``0.0`` See Also From 261a9a93f5a18431129422b0f794bfb377d139c9 Mon Sep 17 00:00:00 2001 From: Rodrigo Pena Date: Tue, 11 Jun 2019 16:38:54 +0200 Subject: [PATCH 219/365] Update _layout.py --- pygsp/graphs/_layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/_layout.py b/pygsp/graphs/_layout.py index b20fcf89..31127fd1 100644 --- a/pygsp/graphs/_layout.py +++ b/pygsp/graphs/_layout.py @@ -92,10 +92,10 @@ def set_coordinates(self, kind='spring', **kwargs): self.coords[i] = self.info['com_coords'][comm_idx] + \ comm_rad * self.coords[i] elif kind == 'laplacian_eigenmap2D': - self.compute_fourier_basis(n_eigenvectors=2) + self.compute_fourier_basis(n_eigenvectors=3) self.coords = self.U[:, 1:3] elif kind == 'laplacian_eigenmap3D': - self.compute_fourier_basis(n_eigenvectors=3) + self.compute_fourier_basis(n_eigenvectors=4) self.coords = self.U[:, 1:4] else: raise ValueError('Unexpected argument kind={}.'.format(kind)) From 3e625480de1f5a911627f150268424a872d42136 Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 8 Jul 2019 15:00:25 +0200 Subject: [PATCH 220/365] add new sphere graphs --- pygsp/graphs/__init__.py | 3 + pygsp/graphs/nngraphs/spherehealpix.py | 63 ++++ pygsp/graphs/nngraphs/sphereicosahedron.py | 347 +++++++++++++++++++++ pygsp/graphs/sphereequiangular.py | 188 +++++++++++ 4 files changed, 601 insertions(+) create mode 100644 pygsp/graphs/nngraphs/spherehealpix.py create mode 100644 pygsp/graphs/nngraphs/sphereicosahedron.py create mode 100644 pygsp/graphs/sphereequiangular.py diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 49d8bdf2..8e1b327b 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -193,6 +193,7 @@ 'RandomRegular', 'RandomRing', 'Ring', + 'SphereEquiangular', 'StochasticBlockModel', 'SwissRoll', 'Torus' @@ -205,6 +206,8 @@ 'Grid2dImgPatches', 'Sensor', 'Sphere', + 'SphereHealpix', + 'SphereIcosahedron', 'TwoMoons' ] diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py new file mode 100644 index 00000000..de64fc69 --- /dev/null +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +import numpy as np + +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 + +def _import_hp(): + try: + import healpy as hp + except Exception as e: + raise ImportError('Cannot import healpy. Choose another graph ' + 'or try to install it with ' + 'conda install healpy. ' + 'Original exception: {}'.format(e)) + return hp + + +class SphereHealpix(NNGraph): + r"""Spherical-shaped graph using HEALPix sampling scheme [https://healpix.jpl.nasa.gov/] (NN-graph). + + Parameters + ---------- + Nside : int + Resolution of the sampling scheme. It should be a power of 2 (default = 1024) + nest : bool + ordering of the pixels (default = True) + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> G = graphs.SphereHealpix(Nside=4) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1.5) + >>> _ = _ = G.plot(ax=ax2) + + """ + + def __init__(self, Nside=1024, nest=True, **kwargs): + hp = _import_hp() + self.Nside = Nside + self.nest = nest + npix = hp.nside2npix(Nside) + indexes = np.arange(npix) + x, y, z = hp.pix2vec(Nside, indexes, nest=nest) + coords = np.vstack([x, y, z]).transpose() + coords = np.asarray(coords, dtype=np.float32) + ## TODO: n_neighbors in function of Nside + n_neighbors = 6 if Nside==1 else 8 + ## TODO: find optimal sigmas + opt_std = {1: 0.5, 2: 0.15, 4: 0.05, 8: 0.0125, 16: 0.005, 32: 0.001} + try: + sigma = opt_std[Nside] + except: + sigma = 0.001 + + plotting = { + 'vertex_size': 80, + "limits": np.array([-1, 1, -1, 1, -1, 1]) + } + super(SphereHealpix, self).__init__(Xin=coords, k=n_neighbors, center=False, rescale=False, + sigma=sigma, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py new file mode 100644 index 00000000..f802013d --- /dev/null +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- + +import numpy as np + +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 + + + +class SphereIcosahedron(NNGraph): + r"""Spherical-shaped graph based on the projection of the icosahedron (NN-graph). + Code inspired by Max Jiang [https://github.com/maxjiang93/ugscnn/blob/master/meshcnn/mesh.py] + + Parameters + ---------- + level : int + Resolution of the sampling scheme, or how many times the faces are divided (default = 5) + sampling : string + What the pixels represent. Either a vertex or a face (default = 'vertex') + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> G = graphs.SphereIcosahedron(level=1) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1.5) + >>> _ = _ = G.plot(ax=ax2) + + """ + + def __init__(self, level=5, sampling='vertex', **kwargs): + + if sampling not in ['vertex', 'face']: + raise ValueError('Unknown sampling value:' + sampling) + PHI = (1 + np.sqrt(5))/2 + radius = np.sqrt(PHI**2+1) + coords = [0, 1, PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, + 1, PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, + PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0, -1] + coords = np.reshape(coords, (-1,3)) + coords = coords/radius + faces = [1, 2, 7, 1, 7, 10, 1, 10, 9, 1, 9, 5, 1, 5, 2, 2, 7, 12, 12, 7, 8, + 7, 8, 10, 8, 10, 3, 10, 3, 9, 3, 9, 6, 9, 6, 5, 6, 5, 11, 5, 11, 2, + 11, 2, 12, 4, 11, 12, 4, 12, 8, 4, 8, 3, 4, 3, 6, 4, 6, 11] + self.faces = np.reshape(faces, (20,3))-1 + self.level = level + self.intp = None + + coords = self._upward(coords, self.faces) + self.coords = coords + + for i in range(level): + self.divide() + self.normalize() + + if sampling=='face': + self.coords = self.coords[self.faces].mean(axis=1) + + self.lat, self.long = self.xyz2latlong() + + self.npix = len(self.coords) + self.nf = 20 * 4**self.level + self.ne = 30 * 4**self.level + self.nv = self.ne - self.nf + 2 + self.nv_prev = int((self.ne / 4) - (self.nf / 4) + 2) + self.nv_next = int((self.ne * 4) - (self.nf * 4) + 2) + + neighbours = 3 if 'face' in sampling else (5 if level == 0 else 6) + super(SphereIcosahedron, self).__init__(Xin=self.coords, k=neighbours, center=False, rescale=False, **kwargs) + + def divide(self): + """ + Subdivide a mesh into smaller triangles. + """ + faces = self.faces + vertices = self.coords + face_index = np.arange(len(faces)) + + # the (c,3) int set of vertex indices + faces = faces[face_index] + # the (c, 3, 3) float set of points in the triangles + triangles = vertices[faces] + # the 3 midpoints of each triangle edge vstacked to a (3*c, 3) float + src_idx = np.vstack([faces[:, g] for g in [[0, 1], [1, 2], [2, 0]]]) + mid = np.vstack([triangles[:, g, :].mean(axis=1) for g in [[0, 1], + [1, 2], + [2, 0]]]) + mid_idx = (np.arange(len(face_index) * 3)).reshape((3, -1)).T + # for adjacent faces we are going to be generating the same midpoint + # twice, so we handle it here by finding the unique vertices + unique, inverse = self._unique_rows(mid) + + mid = mid[unique] + src_idx = src_idx[unique] + mid_idx = inverse[mid_idx] + len(vertices) + # the new faces, with correct winding + f = np.column_stack([faces[:, 0], mid_idx[:, 0], mid_idx[:, 2], + mid_idx[:, 0], faces[:, 1], mid_idx[:, 1], + mid_idx[:, 2], mid_idx[:, 1], faces[:, 2], + mid_idx[:, 0], mid_idx[:, 1], mid_idx[:, 2], ]).reshape((-1, 3)) + # add the 3 new faces per old face + new_faces = np.vstack((faces, f[len(face_index):])) + # replace the old face with a smaller face + new_faces[face_index] = f[:len(face_index)] + + new_vertices = np.vstack((vertices, mid)) + # source ids + nv = vertices.shape[0] + identity_map = np.stack((np.arange(nv), np.arange(nv)), axis=1) + src_id = np.concatenate((identity_map, src_idx), axis=0) + + self.coords = new_vertices + self.faces = new_faces + self.intp = src_id + + def normalize(self, radius=1): + ''' + Reproject to spherical surface + ''' + vectors = self.coords + scalar = (vectors ** 2).sum(axis=1)**.5 + unit = vectors / scalar.reshape((-1, 1)) + offset = radius - scalar + self.coords += unit * offset.reshape((-1, 1)) + + def xyz2latlong(self): + x, y, z = self.coords[:, 0], self.coords[:, 1], self.coords[:, 2] + long = np.arctan2(y, x) + xy2 = x**2 + y**2 + lat = np.arctan2(z, np.sqrt(xy2)) + return lat, long + + def _upward(self, V_ico, F_ico, ind=11): + V0 = V_ico[ind] + Z0 = np.array([0, 0, 1]) + k = np.cross(V0, Z0) + ct = np.dot(V0, Z0) + st = -np.linalg.norm(k) + R = self._rot_matrix(k, ct, st) + V_ico = V_ico.dot(R) + # rotate a neighbor to align with (+y) + ni = self._find_neighbor(F_ico, ind)[0] + vec = V_ico[ni].copy() + vec[2] = 0 + vec = vec/np.linalg.norm(vec) + y_ = np.eye(3)[1] + + k = np.eye(3)[2] + crs = np.cross(vec, y_) + ct = -np.dot(vec, y_) + st = -np.sign(crs[-1])*np.linalg.norm(crs) + R2 = self._rot_matrix(k, ct, st) + V_ico = V_ico.dot(R2) + return V_ico + + def _find_neighbor(self, F, ind): + """find a icosahedron neighbor of vertex i""" + FF = [F[i] for i in range(F.shape[0]) if ind in F[i]] + FF = np.concatenate(FF) + FF = np.unique(FF) + neigh = [f for f in FF if f != ind] + return neigh + + def _rot_matrix(self, rot_axis, cos_t, sin_t): + k = rot_axis / np.linalg.norm(rot_axis) + I = np.eye(3) + + R = [] + for i in range(3): + v = I[i] + vr = v*cos_t+np.cross(k, v)*sin_t+k*(k.dot(v))*(1-cos_t) + R.append(vr) + R = np.stack(R, axis=-1) + return R + + def _ico_rot_matrix(self, ind): + """ + return rotation matrix to perform permutation corresponding to + moving a certain icosahedron node to the top + """ + v0_ = self.v0.copy() + f0_ = self.f0.copy() + V0 = v0_[ind] + Z0 = np.array([0, 0, 1]) + + # rotate the point to the top (+z) + k = np.cross(V0, Z0) + ct = np.dot(V0, Z0) + st = -np.linalg.norm(k) + R = self._rot_matrix(k, ct, st) + v0_ = v0_.dot(R) + + # rotate a neighbor to align with (+y) + ni = self._find_neighbor(f0_, ind)[0] + vec = v0_[ni].copy() + vec[2] = 0 + vec = vec/np.linalg.norm(vec) + y_ = np.eye(3)[1] + + k = np.eye(3)[2] + crs = np.cross(vec, y_) + ct = np.dot(vec, y_) + st = -np.sign(crs[-1])*np.linalg.norm(crs) + + R2 = self._rot_matrix(k, ct, st) + return R.dot(R2) + + def _rotseq(self, V, acc=9): + """sequence to move an original node on icosahedron to top""" + seq = [] + for i in range(11): + Vr = V.dot(self._ico_rot_matrix(i)) + # lexsort + s1 = np.lexsort(np.round(V.T, acc)) + s2 = np.lexsort(np.round(Vr.T, acc)) + s = s1[np.argsort(s2)] + seq.append(s) + return tuple(seq) + + def _unique_rows(self, data, digits=None): + """ + Returns indices of unique rows. It will return the + first occurrence of a row that is duplicated: + [[1,2], [3,4], [1,2]] will return [0,1] + Parameters + --------- + data: (n,m) set of floating point data + digits: how many digits to consider for the purposes of uniqueness + Returns + -------- + unique: (j) array, index in data which is a unique row + inverse: (n) length array to reconstruct original + example: unique[inverse] == data + """ + hashes = self._hashable_rows(data, digits=digits) + garbage, unique, inverse = np.unique(hashes, + return_index=True, + return_inverse=True) + return unique, inverse + + def _hashable_rows(self, data, digits=None): + """ + We turn our array into integers based on the precision + given by digits and then put them in a hashable format. + Parameters + --------- + data: (n,m) input array + digits: how many digits to add to hash, if data is floating point + If none, TOL_MERGE will be turned into a digit count and used. + Returns + --------- + hashable: (n) length array of custom data which can be sorted + or used as hash keys + """ + # if there is no data return immediatly + if len(data) == 0: + return np.array([]) + + # get array as integer to precision we care about + as_int = self._float_to_int(data, digits=digits) + + # if it is flat integers already, return + if len(as_int.shape) == 1: + return as_int + + # if array is 2D and smallish, we can try bitbanging + # this is signifigantly faster than the custom dtype + if len(as_int.shape) == 2 and as_int.shape[1] <= 4: + # time for some righteous bitbanging + # can we pack the whole row into a single 64 bit integer + precision = int(np.floor(64 / as_int.shape[1])) + # if the max value is less than precision we can do this + if np.abs(as_int).max() < 2**(precision - 1): + # the resulting package + hashable = np.zeros(len(as_int), dtype=np.int64) + # loop through each column and bitwise xor to combine + # make sure as_int is int64 otherwise bit offset won't work + for offset, column in enumerate(as_int.astype(np.int64).T): + # will modify hashable in place + np.bitwise_xor(hashable, + column << (offset * precision), + out=hashable) + return hashable + + # reshape array into magical data type that is weird but hashable + dtype = np.dtype((np.void, as_int.dtype.itemsize * as_int.shape[1])) + # make sure result is contiguous and flat + hashable = np.ascontiguousarray(as_int).view(dtype).reshape(-1) + return hashable + + def _float_to_int(self, data, digits=None, dtype=np.int32): + """ + Given a numpy array of float/bool/int, return as integers. + Parameters + ------------- + data: (n, d) float, int, or bool data + digits: float/int precision for float conversion + dtype: numpy dtype for result + Returns + ------------- + as_int: data, as integers + """ + # convert to any numpy array + data = np.asanyarray(data) + + # if data is already an integer or boolean we're done + # if the data is empty we are also done + if data.dtype.kind in 'ib' or data.size == 0: + return data.astype(dtype) + + # populate digits from kwargs + if digits is None: + digits = self._decimal_to_digits(1e-8) + elif isinstance(digits, float) or isinstance(digits, np.float): + digits = _decimal_to_digits(digits) + elif not (isinstance(digits, int) or isinstance(digits, np.integer)): + log.warn('Digits were passed as %s!', digits.__class__.__name__) + raise ValueError('Digits must be None, int, or float!') + + # data is float so convert to large integers + data_max = np.abs(data).max() * 10**digits + # ignore passed dtype if we have something large + dtype = [np.int32, np.int64][int(data_max > 2**31)] + # multiply by requested power of ten + # then subtract small epsilon to avoid "go either way" rounding + # then do the rounding and convert to integer + as_int = np.round((data * 10 ** digits) - 1e-6).astype(dtype) + + return as_int + + + def _decimal_to_digits(self, decimal, min_digits=None): + """ + Return the number of digits to the first nonzero decimal. + Parameters + ----------- + decimal: float + min_digits: int, minumum number of digits to return + Returns + ----------- + digits: int, number of digits to the first nonzero decimal + """ + digits = abs(int(np.log10(decimal))) + if min_digits is not None: + digits = np.clip(digits, min_digits, 20) + return digits diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py new file mode 100644 index 00000000..6bade6d8 --- /dev/null +++ b/pygsp/graphs/sphereequiangular.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- + +import numpy as np +from scipy import sparse + +from pygsp.graphs import Graph # prevent circular import in Python < 3.5 + + +def _import_hp(): + try: + import healpy as hp + except Exception as e: + raise ImportError('Cannot import healpy. Choose another graph ' + 'or try to install it with ' + 'conda install healpy. ' + 'Original exception: {}'.format(e)) + return hp + +class SphereEquiangular(Graph): + r"""Spherical-shaped graph using equirectangular sampling scheme. + + Parameters + ---------- + bw : int or list or tuple + Resolution of the sampling scheme, corresponding to the bandwidth. + Use a list or tuple to have a different resolution for latitude and longitude (default = 64) + sptype : string + sampling type (default = 'SOFT') + * DH original Driscoll-Healy + * SOFT equiangular without poles + * CC use of Clenshaw-Curtis quadrature + * GL use of Gauss-Legendre quadrature + * OD optimal dimensionality + dist : string + type of distance use to compute edge weights, euclidean or geodesic (default = 'euclidean') + cylinder : bool + adapt the grid on a cylinder + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> G = graphs.SphereEquiangular(bw=8, sptype='SOFT') + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1.5) + >>> _ = _ = G.plot(ax=ax2) + + """ + def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwargs): + if isinstance(bw, int): + bw = (bw, bw) + elif len(bw)>2: + raise ValueError('Cannot have more than two bandwidths') + self.bw = bw + self.sptype = sptype + if sptype not in ['DH', 'SOFT', 'CC', 'GL', 'OD']: + raise ValueError('Unknown sampling type:' + sptype) + if dist not in ['geodesic', 'euclidean']: + raise ValueError('Unknown distance type value:' + dist) + + ## sampling and coordinates calculation + if sptype is 'DH': + beta = np.arange(2 * bw[0]) * np.pi / (2. * bw[0]) # Driscoll-Heally + alpha = np.arange(2 * bw[1]) * np.pi / bw[1] + elif sptype is 'SOFT': # SO(3) Fourier Transform optimal + beta = np.pi * (2 * np.arange(2 * bw[0]) + 1) / (4. * bw[0]) + alpha = np.arange(2 * bw[1]) * np.pi / bw[1] + elif sptype == 'CC': # Clenshaw-Curtis + beta = np.linspace(0, np.pi, 2 * bw[0] + 1) + alpha = np.linspace(0, 2 * np.pi, 2 * bw[1] + 2, endpoint=False) + elif sptype == 'GL': # Gauss-legendre + try: + from numpy.polynomial.legendre import leggauss + except: + raise ImportError("cannot import legendre quadrature from numpy." + "Choose another sampling type or upgrade numpy.") + x, _ = leggauss(bw[0] + 1) # TODO: leggauss docs state that this may not be only stable for orders > 100 + beta = np.arccos(x) + alpha = np.arange(2 * bw[1] + 2) * np.pi / (bw[1] + 1) + if sptype == 'OD': # Optimal Dimensionality + theta, phi = np.zeros(4*bw[0]**2), np.zeros(4*bw[0]**2) + index=0 + #beta = np.pi * (2 * np.arange(2 * bw) + 1) / (4. * bw) + beta = np.pi * ((np.arange(2 * bw[0] + 1)%2)*(4*bw[0]-1)+np.arange(2 * bw[0] + 1)*- + 1**(np.arange(2 * bw[0] + 1)%2)) / (4 * bw[0] - 1) + for i in range(2*bw[0]): + alpha = 2 * np.pi * np.arange(2 * i + 1) / (2 * i + 1) + end = len(alpha) + theta[index:index+end], phi[index:index+end] = np.repeat(beta[i], end), alpha + index += end + else: + theta, phi = np.meshgrid(*(beta, alpha),indexing='ij') + self.lat, self.lon = theta.shape + if cylinder: + ct = theta.flatten() * 2 * bw[1] / np.pi + st = 1 + else: + ct = np.cos(theta).flatten() + st = np.sin(theta).flatten() + cp = np.cos(phi).flatten() + sp = np.sin(phi).flatten() + x = st * cp + y = st * sp + z = ct + coords = np.vstack([x, y, z]).T + coords = np.asarray(coords, dtype=np.float32) + self.npix = len(coords) + + ## neighbors and weight matrix calculation + def south(x): + if x >= self.npix - self.lat: + return (x + self.lat//2)%self.lat + self.npix - self.lat + return x + self.lon + + def north(x): + if x < self.lat: + return (x + self.lat//2)%self.lat + return x - self.lon + + def west(x): + if x%(self.lon)<1: + try: + assert x//self.lat == (x-1+self.lon)//self.lat + except: + raise + x += self.lon + else: + try: + assert x//self.lat == (x-1)//self.lat + except: + raise + return x - 1 + + def east(x): + if x%(self.lon)>=self.lon-1: + try: + assert x//self.lat == (x+1-self.lon)//self.lat + except: + raise + x -= self.lon + else: + try: + assert x//self.lat == (x+1)//self.lat + except: + raise + return x + 1 + + col_index=[] + for ind in range(self.npix): + # if neighbors==8: + # neighbor = [south(west(ind)), west(ind), north(west(ind)), north(ind), + # north(east(ind)), east(ind), south(east(ind)), south(ind)] + # elif neighbors==4: + neighbor = [west(ind), north(ind), east(ind), south(ind)] + # else: + # neighbor = [] + col_index += neighbor + col_index = np.asarray(col_index) + row_index = np.repeat(np.arange(self.npix), 4) + + keep = (col_index < self.npix) + keep &= (col_index >= 0) + col_index = col_index[keep] + row_index = row_index[keep] + + if dist=='geodesic': + hp = _import_hp() + distances = np.zeros(len(row_index)) + for i, (pos1, pos2) in enumerate(zip(coords[row_index], coords[col_index])): + d1, d2 = hp.rotator.vec2dir(pos1.T, lonlat=False).T, hp.rotator.vec2dir(pos2.T, lonlat=False).T + distances[i] = hp.rotator.angdist(d1, d2, lonlat=False) + else: + distances = np.sum((coords[row_index] - coords[col_index])**2, axis=1) + + # Compute similarities / edge weights. + kernel_width = np.mean(distances) + + # weights = np.exp(-distances / (2 * kernel_width)) + weights = 1/distances + + W = sparse.csr_matrix( + (weights, (row_index, col_index)), shape=(self.npix, self.npix), dtype=np.float32) + + plotting = {"limits": np.array([-1, 1, -1, 1, -1, 1])} + super(SphereEquiangular, self).__init__(adjacency=W, coords=coords, + plotting=plotting, **kwargs) + From a6e23b6d6002dad0c23ebd58758b6788f2f414b4 Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 8 Jul 2019 18:47:57 +0200 Subject: [PATCH 221/365] icosahedron similar to jiang data --- pygsp/graphs/nngraphs/sphereicosahedron.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index f802013d..3477a5df 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -35,14 +35,15 @@ def __init__(self, level=5, sampling='vertex', **kwargs): raise ValueError('Unknown sampling value:' + sampling) PHI = (1 + np.sqrt(5))/2 radius = np.sqrt(PHI**2+1) - coords = [0, 1, PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, - 1, PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, - PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0, -1] + coords = [-1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0, + 0, -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, + PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, 1] coords = np.reshape(coords, (-1,3)) coords = coords/radius - faces = [1, 2, 7, 1, 7, 10, 1, 10, 9, 1, 9, 5, 1, 5, 2, 2, 7, 12, 12, 7, 8, - 7, 8, 10, 8, 10, 3, 10, 3, 9, 3, 9, 6, 9, 6, 5, 6, 5, 11, 5, 11, 2, - 11, 2, 12, 4, 11, 12, 4, 12, 8, 4, 8, 3, 4, 3, 6, 4, 6, 11] + faces = [0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, + 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, + 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, + 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1] self.faces = np.reshape(faces, (20,3))-1 self.level = level self.intp = None From 4f73fef0fe9a1cb2e5fcda6a3fae0e6285e5f5b2 Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 15 Jul 2019 15:13:12 +0200 Subject: [PATCH 222/365] correct icosahedron and install healpy --- pygsp/graphs/nngraphs/sphereicosahedron.py | 5 +++-- setup.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index 3477a5df..a2553f15 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -20,6 +20,7 @@ class SphereIcosahedron(NNGraph): Examples -------- >>> import matplotlib.pyplot as plt + >>> from mpl_toolkits.mplot3d import Axes3D >>> G = graphs.SphereIcosahedron(level=1) >>> fig = plt.figure() >>> ax1 = fig.add_subplot(121) @@ -44,7 +45,7 @@ def __init__(self, level=5, sampling='vertex', **kwargs): 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1] - self.faces = np.reshape(faces, (20,3))-1 + self.faces = np.reshape(faces, (20, 3)) self.level = level self.intp = None @@ -127,7 +128,7 @@ def normalize(self, radius=1): def xyz2latlong(self): x, y, z = self.coords[:, 0], self.coords[:, 1], self.coords[:, 2] - long = np.arctan2(y, x) + long = np.arctan2(y, x) + np.pi xy2 = x**2 + y**2 lat = np.arctan2(z, np.sqrt(xy2)) return lat, long diff --git a/setup.py b/setup.py index 37849940..746be6ce 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,8 @@ # 'graph-tool', cannot be installed by pip # Construct patch graphs from images. 'scikit-image', + # Construct Sphere graph + 'healpy', # Approximate nearest neighbors for kNN graphs. 'cyflann', 'nmslib', From bc4590cc686791249c0d3e09c222b99c8023afdd Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 6 Aug 2018 13:41:38 +0800 Subject: [PATCH 223/365] checking for small difference between matrix require an absolute value --- pygsp/filters/filter.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 7e36f11c..d7d71528 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -574,6 +574,7 @@ def compute_frame(self, **kwargs): >>> s1 = gL.dot(s) >>> s1 = s1.reshape(G.N, -1, order='F') >>> +<<<<<<< HEAD >>> s2 = g.filter(s) >>> np.all(np.abs(s1 - s2) < 1e-10) True @@ -589,6 +590,10 @@ def compute_frame(self, **kwargs): >>> gL.shape (600, 100) >>> np.all(gL.T.dot(gL) - np.identity(G.N) < 1e-10) +======= + >>> s2 = f.filter(s) + >>> np.all(np.abs(s1 - s2) < 1e-10) +>>>>>>> checking for small difference between matrix require an absolute value True """ From f6cc2b0fbc8039e796aa77069f0ac33ffed19d47 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 29 Aug 2018 23:56:00 +0800 Subject: [PATCH 224/365] Adding import from Networkx and graph tool lib change tempoary implemented in @classmethods and still not tested. Futhermore some optimization could be done especialy on the import from graph tool --- pygsp/graphs/graph.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d5a72c12..dc96f385 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -190,6 +190,47 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) + def to_networkx(self): + r"""Doc TODO""" + import networkx as nx + return nx.from_scipy_sparse_matrix(self.W) + + def to_graphtool(self, directed=False): + r"""Doc TODO""" + ##from graph_tool.all import * + import graph_tool + g = graph_tool.Graph(directed=directed) #TODO check for undirected graph + nonzero = self.W.nonzero() + g.add_edge_list(np.transpose(nonzero)) + edge_weight = g.new_edge_property("double") + edge_weight.a = np.squeeze(np.array(self.W[nonzero])) + g.edge_properties["weight"] = edge_weight + return g + + @classmethod + def from_networkx(cls, graph_nx): + r"""Doc TODO""" + import networkx as nx + A = nx.to_scipy_sparse_matrix(graph_nx) + G = cls(A) + return G + + @classmethod + def from_graphtool(cls, graph_gt): + r"""Doc TODO""" + nb_vertex = len(graph_gt.get_vertices()) + edge_weight = np.ones(nb_vertex) + W = np.zeros(shape=(nb_vertex, nb_vertex)) + + props_names = graph_gt.edge_properties.keys() + if "weight" in props_names: + prop = graph_gt.edge_properties["weight"] + edge_weight = prop.get_array() + + for e in graph_gt.get_edges(): + W[e[0], e[1]] = edge_weight[e[2]] + return cls(W) + def check_weights(self): r"""Check the characteristics of the weights matrix. From f23f7ad2d80b9aaff3b487e0191cd778e533bad6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Fri, 7 Sep 2018 00:05:53 +0800 Subject: [PATCH 225/365] Documentation The documentation for the import and export methods have been started --- pygsp/graphs/graph.py | 69 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index dc96f385..d7a65e5e 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -191,33 +191,80 @@ def __repr__(self, limit=None): return '{}({})'.format(self.__class__.__name__, s[:-2]) def to_networkx(self): - r"""Doc TODO""" + r"""Export the graph to an `Networkx `_ object + + Returns + ------- + g_nx : `Graph `_ + """ import networkx as nx return nx.from_scipy_sparse_matrix(self.W) - def to_graphtool(self, directed=False): - r"""Doc TODO""" + def to_graphtool(self, edge_prop_name='weight', directed=True): + r"""Export the graph to an `Graph tool `_ object + The weights of the graph are stored in a `property maps `_ + of type double + + + Parameters + ---------- + edge_prop_name : string + Name of the `property `_. + By default it is set to `weight` + directed : bool + Indicate if the graph is `directed `_ + + Returns + ------- + g_gt : `Graph `_ + """ ##from graph_tool.all import * import graph_tool - g = graph_tool.Graph(directed=directed) #TODO check for undirected graph + g_gt = graph_tool.Graph(directed=directed) #TODO check for undirected graph nonzero = self.W.nonzero() - g.add_edge_list(np.transpose(nonzero)) - edge_weight = g.new_edge_property("double") + g_gt.add_edge_list(np.transpose(nonzero)) + edge_weight = g_gt.new_edge_property('double') edge_weight.a = np.squeeze(np.array(self.W[nonzero])) - g.edge_properties["weight"] = edge_weight - return g + g_gt.edge_properties[edge_prop_name] = edge_weight + return g_gt @classmethod def from_networkx(cls, graph_nx): - r"""Doc TODO""" + r"""Build a graph from a Networkx object + + Parameters + ---------- + graph_nx : Graph + A netowrkx instance of a graph + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + """ + import networkx as nx A = nx.to_scipy_sparse_matrix(graph_nx) G = cls(A) return G @classmethod - def from_graphtool(cls, graph_gt): - r"""Doc TODO""" + def from_graphtool(cls, graph_gt, edge_prop_name='weight'): + r"""Build a graph from a graph tool object. + + Parameters + ---------- + graph_gt : Graph + Graph tool object + edge_prop_name : string + Name of the `property `_ + to be loaded as weight for the graph + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + The weight of the graph are loaded from the edge property named ``edge_prop_name`` + + """ nb_vertex = len(graph_gt.get_vertices()) edge_weight = np.ones(nb_vertex) W = np.zeros(shape=(nb_vertex, nb_vertex)) From 7fb7a2b20cebb410a7d388ad61cff99f1314a0ee Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 10 Sep 2018 00:08:37 +0800 Subject: [PATCH 226/365] Save and Load Work in progress. not tested but the 'gml' format has been implemented --- pygsp/graphs/graph.py | 84 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d7a65e5e..c9ac4f03 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -276,7 +276,89 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): for e in graph_gt.get_edges(): W[e[0], e[1]] = edge_weight[e[2]] - return cls(W) + return cls(W) + + @classmethod + def load(cls, path, fmt='auto', lib='networkx'): + r"""Load a graph from a file using networkx for import. + The format is guessed from path, or can be specified by fmt + + Parameters + ---------- + path : String + Where the file is located on the disk. + fmt : String + Format in which the graph is encoded. Currently supported format are: + GML, gpickle. + lib : String + Python library used in background to load the graph. + Supported library are networkx and graph_tool + + Returns + ------- + g : :class:`~pygsp.graphs.Graph` + + """ + if fmt == 'auto': + fmt = path.split('.')[-1] + + exec('import ' + lib) + + err = NotImplementedError('{} can not be load with {}. \ + Try another background library'.format(fmt, lib)) + + if fmt == 'gml': + if lib == 'networkx': + g = networkx.read_gml(path) + return from_networkx(g) + if lib == 'graph_tool': + g = graph_tool.load_graph(path, fmt=fmt) + return from_graphtool(g) + raise err + + if fmt in ['gpickle', 'p', 'pkl', 'pickle']: + if lib == 'networkx': + g = networkx.read_gpickle(path) + return from_networkx(g) + raise err + + raise NotImplementedError('the format {} is not suported'.format(fmt)) + + def save(self, path, fmt='auto', lib='networkx'): + r"""Save the graph into a file + + Parameters + ---------- + path : String + Where to save file on the disk. + fmt : String + Format in which the graph will be encoded. The format is guessed from + the `path` extention when fmt is set to 'auto' + Currently supported format are: + GML, gpickle. + lib : String + Python library used in background to save the graph. + Supported library are networkx and graph_tool + + + """ + if fmt == 'auto': + fmt = path.split('.')[-1] + + exec('import ' + lib) + + if fmt == 'gml': + if lib == 'networkx': + g = to_networkx() + networkx.write_gml(g, path) + return + if lib == 'graph_tool': + g = to_graphtool() + g.save(path, fmt=fmt) + raise err + + raise NotImplementedError('the format {} is not suported'.format(fmt)) + def check_weights(self): From 96debbfbb4886415354ba3a36fda121f11b63440 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 12 Sep 2018 00:23:00 +0800 Subject: [PATCH 227/365] Import from graph tool by summing over multiple edge --- pygsp/graphs/graph.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index c9ac4f03..2994c62b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -7,7 +7,8 @@ import numpy as np from scipy import sparse - +from itertools import groupby +import warnings from pygsp import utils from .fourier import FourierMixIn from .difference import DifferenceMixIn @@ -270,12 +271,20 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): W = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() - if "weight" in props_names: - prop = graph_gt.edge_properties["weight"] + + if edge_prop_name in props_names: + prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() - - for e in graph_gt.get_edges(): - W[e[0], e[1]] = edge_weight[e[2]] + else: + warnings.warn("""{} property not found in the graph, \ + weights of 1 for the edges are set""".format(edge_prop_name)) + edge_weight = np.ones(len(g.edges)) + # merging multi-edge + merged_edge_weight = [] + for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): + merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) + for e in merged_edge_weight: + W[e[0], e[1]] = e[2] return cls(W) @classmethod From 3a4c228fc765dafa7d47d8cc64e81985a0527e39 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 12 Sep 2018 00:23:53 +0800 Subject: [PATCH 228/365] Fix bug when creating a random graph_tool graph --- pygsp/tests/test_graphs.py | 240 ++++++------------------------------- 1 file changed, 34 insertions(+), 206 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 44d7be9c..ec2e45ea 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -731,210 +731,38 @@ def test_graphtool_multiedge_import(self): @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_import_export(self): - # Import to PyGSP and export again to graph tool directly - # create a random graphTool graph that does not contain multiple edges and no signal - graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) - - eprop_double = graph_gt.new_edge_property("double") - for e in graph_gt.edges(): + import graph_tool as gt + bunny = graphs.Bunny() + gt_bunny = bunny.to_graphtool() + new_bunny = graphs.Graph.from_graphtool(gt_bunny) + np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) + + #create a random graphTool graph + g = gt.Graph() + g.add_vertex(100) + # insert some random links + eprop_double = g.new_edge_property("double") + for s, t in zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100)): + g.add_edge(g.vertex(s), g.vertex(t)) + + for e in g.edges(): eprop_double[e] = random.random() - graph_gt.edge_properties["weight"] = eprop_double - - graph2_gt = graphs.Graph.from_graphtool(graph_gt).to_graphtool() - - self.assertEqual(graph_gt.num_edges(), graph2_gt.num_edges(), - "the number of edges does not correspond") - - def key(edge): return str(edge.source()) + ":" + str(edge.target()) - - for e1, e2 in zip(sorted(graph_gt.edges(), key=key), sorted(graph2_gt.edges(), key=key)): - self.assertEqual(e1.source(), e2.source()) - self.assertEqual(e1.target(), e2.target()) - for v1, v2 in zip(graph_gt.vertices(), graph2_gt.vertices()): - self.assertEqual(v1, v2) - - def test_networkx_signal_export(self): - graph = graphs.BarabasiAlbert(N=100, seed=42) - rs = np.random.RandomState(42) - signal1 = rs.normal(size=graph.N) - signal2 = rs.normal(size=graph.N) - graph.set_signal(signal1, "signal1") - graph.set_signal(signal2, "signal2") - graph_nx = graph.to_networkx() - for i in range(graph.N): - self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) - self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) - # invalid signal type - graph = graphs.Path(3) - graph.set_signal(np.array(['a', 'b', 'c']), 'sig') - self.assertRaises(ValueError, graph.to_networkx) - - def test_graphtool_signal_export(self): - g = graphs.Logo() - rs = np.random.RandomState(42) - s = rs.normal(size=g.N) - s2 = rs.normal(size=g.N) - g.set_signal(s, "signal1") - g.set_signal(s2, "signal2") - g_gt = g.to_graphtool() - # Check the signals on all nodes - for i, v in enumerate(g_gt.vertices()): - self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) - self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) - # invalid signal type - graph = graphs.Path(3) - graph.set_signal(np.array(['a', 'b', 'c']), 'sig') - self.assertRaises(TypeError, graph.to_graphtool) - - @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') - def test_graphtool_signal_import(self): - g_gt = gt.Graph() - g_gt.add_vertex(10) - - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) - - vprop_double = g_gt.new_vertex_property("double") - - vprop_double[g_gt.vertex(0)] = 5 - vprop_double[g_gt.vertex(1)] = -3 - vprop_double[g_gt.vertex(2)] = 2.4 - - g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt) - self.assertEqual(g.signals["signal"][0], 5.0) - self.assertEqual(g.signals["signal"][1], -3.0) - self.assertEqual(g.signals["signal"][2], 2.4) - - def test_networkx_signal_import(self): - graph_nx = nx.Graph() - graph_nx.add_nodes_from(range(2, 5)) - graph_nx.add_edges_from([(3, 4), (2, 4), (3, 5)]) - nx.set_node_attributes(graph_nx, {2: 4, 3: 5, 5: 2.3}, "s") - graph_pg = graphs.Graph.from_networkx(graph_nx) - np.testing.assert_allclose(graph_pg.signals["s"], [4, 5, np.nan, 2.3]) - - def test_no_weights(self): - - adjacency = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) - - # NetworkX no weights. - graph_nx = nx.Graph() - graph_nx.add_edge(0, 1) - graph_nx.add_edge(1, 2) - graph_pg = graphs.Graph.from_networkx(graph_nx) - np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) - - # NetworkX non-existent weight name. - graph_nx.edges[(0, 1)]['weight'] = 2 - graph_nx.edges[(1, 2)]['weight'] = 2 - graph_pg = graphs.Graph.from_networkx(graph_nx) - np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) - graph_pg = graphs.Graph.from_networkx(graph_nx, weight='unknown') - np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) - - # Graph-tool no weights. - graph_gt = gt.Graph(directed=False) - graph_gt.add_edge(0, 1) - graph_gt.add_edge(1, 2) - graph_pg = graphs.Graph.from_graphtool(graph_gt) - np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) - - # Graph-tool non-existent weight name. - prop = graph_gt.new_edge_property("double") - prop[(0, 1)] = 2 - prop[(1, 2)] = 2 - graph_gt.edge_properties["weight"] = prop - graph_pg = graphs.Graph.from_graphtool(graph_gt) - np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) - graph_pg = graphs.Graph.from_graphtool(graph_gt, weight='unknown') - np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) - - def test_break_join_signals(self): - """Multi-dim signals are broken on export and joined on import.""" - graph1 = graphs.Sensor(20, seed=42) - graph1.set_signal(graph1.coords, 'coords') - # networkx - graph2 = graph1.to_networkx() - graph2 = graphs.Graph.from_networkx(graph2) - np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) - # graph-tool - graph2 = graph1.to_graphtool() - graph2 = graphs.Graph.from_graphtool(graph2) - np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) - # save and load (need ordered dicts) - if sys.version_info >= (3, 6): - filename = 'graph.graphml' - graph1.save(filename) - graph2 = graphs.Graph.load(filename) - np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) - os.remove(filename) - - @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') - def test_save_load(self): - - # TODO: test with multiple graphs and signals - # * dtypes (float, int, bool) of adjacency and signals - # * empty graph / isolated nodes - - G1 = graphs.Sensor(seed=42) - W = G1.W.toarray() - sig = np.random.RandomState(42).normal(size=G1.N) - G1.set_signal(sig, 's') - - for fmt in ['graphml', 'gml', 'gexf']: - for backend in ['networkx', 'graph-tool']: - - if fmt == 'gexf' and backend == 'graph-tool': - self.assertRaises(ValueError, G1.save, 'g', fmt, backend) - self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt, - backend) - os.remove('g') - continue - - atol = 1e-5 if fmt == 'gml' and backend == 'graph-tool' else 0 - - for filename, fmt in [('graph.' + fmt, None), ('graph', fmt)]: - G1.save(filename, fmt, backend) - G2 = graphs.Graph.load(filename, fmt, backend) - np.testing.assert_allclose(G2.W.toarray(), W, atol=atol) - np.testing.assert_allclose(G2.signals['s'], sig, atol=atol) - os.remove(filename) - - self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', fmt='?') - self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', backend='?') - self.assertRaises(ValueError, G1.save, 'g.gml', fmt='?') - self.assertRaises(ValueError, G1.save, 'g.gml', backend='?') - - @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') - def test_import_errors(self): - from unittest.mock import patch - graph = graphs.Sensor() - filename = 'graph.gml' - with patch.dict(sys.modules, {'networkx': None}): - self.assertRaises(ImportError, graph.to_networkx) - self.assertRaises(ImportError, graphs.Graph.from_networkx, None) - self.assertRaises(ImportError, graph.save, filename, - backend='networkx') - self.assertRaises(ImportError, graphs.Graph.load, filename, - backend='networkx') - graph.save(filename) - graphs.Graph.load(filename) - with patch.dict(sys.modules, {'graph_tool': None}): - self.assertRaises(ImportError, graph.to_graphtool) - self.assertRaises(ImportError, graphs.Graph.from_graphtool, None) - self.assertRaises(ImportError, graph.save, filename, - backend='graph-tool') - self.assertRaises(ImportError, graphs.Graph.load, filename, - backend='graph-tool') - graph.save(filename) - graphs.Graph.load(filename) - with patch.dict(sys.modules, {'networkx': None, 'graph_tool': None}): - self.assertRaises(ImportError, graph.save, filename) - self.assertRaises(ImportError, graphs.Graph.load, filename) - os.remove(filename) - - -suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestImportExport) -suite = unittest.TestSuite([suite_graphs, suite_import_export]) + g.edge_properties["weight"] = eprop_double + # this assigns random values to the vertex properties (this is a signal) + vprop_double = g.new_vertex_property("double") + vprop_double.get_array()[:] = np.random.random(g.num_vertices()) + g.vertex_properties["signal"] = vprop_double + + new_g = graphs.Graph.from_graphtool(g).to_graphtool() + key = lambda e: str(e.source()) + ":" + str(e.target()) + assert len([e for e in g.edges()]) == len([e for e in new_g.edges()]),\ + "the number of edge does not correspond" + #TODO check if in graph tool its normal to have multiple edges between two vertex + for e1,e2 in zip(sorted(g.edges(), key= key), sorted(new_g.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g.vertices(), new_g.vertices()): + assert v1 == v2 + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 01a5511aebada6215c2e68c8b2a65de00514d5f1 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 09:51:42 +0800 Subject: [PATCH 229/365] custom aggragation function implemented for merging multi-edges --- pygsp/graphs/graph.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 2994c62b..129a39f9 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -249,7 +249,7 @@ def from_networkx(cls, graph_nx): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight'): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): r"""Build a graph from a graph tool object. Parameters @@ -259,6 +259,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): edge_prop_name : string Name of the `property `_ to be loaded as weight for the graph + aggr_fun : function + When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the + edges. By default the sum is taken. Returns ------- @@ -282,7 +285,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight'): # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) + merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] return cls(W) From e514e204cb37b2ab27e75d0414a6f094ceac2abf Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 11:40:31 +0800 Subject: [PATCH 230/365] Export signal to networkx & graph_tool --- pygsp/graphs/graph.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 129a39f9..eabdc221 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -199,14 +199,18 @@ def to_networkx(self): g_nx : `Graph `_ """ import networkx as nx - return nx.from_scipy_sparse_matrix(self.W) + g = nx.from_scipy_sparse_matrix(self.W) + for key in self.signals: + dic_signal = { i : self.signals[key][i] for i in range(0, len(self.signals[key]) ) } + nx.set_node_attributes(g, dic_signal, key) + return g def to_graphtool(self, edge_prop_name='weight', directed=True): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double + WARNING: The edges and vertex property will be converted into double type - Parameters ---------- edge_prop_name : string @@ -227,6 +231,10 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): edge_weight = g_gt.new_edge_property('double') edge_weight.a = np.squeeze(np.array(self.W[nonzero])) g_gt.edge_properties[edge_prop_name] = edge_weight + for key in self.signals: + vprop_double = g_gt.new_vertex_property("double") + vprop_double.get_array()[:] = self.signals[key] + g_gt.vertex_properties[key] = vprop_double return g_gt @classmethod @@ -371,7 +379,19 @@ def save(self, path, fmt='auto', lib='networkx'): raise NotImplementedError('the format {} is not suported'.format(fmt)) + def set_signal(self, signal, signal_name): + r""" + Add or modify a signal to the graph + Parameters + ---------- + signal : numpy.array + An array maping from node to his value. For example the value of the singal at node i is signal[i] + signal_name : String + Name associated to the signal. + """ + assert len(signal) == self.N, "A value must be attached to every vertex in the graph" + self.signals[signal_name] = np.array(signal) def check_weights(self): r"""Check the characteristics of the weights matrix. From 0f05d964042d308d4a852b810d86c7bb1f6ccfd2 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:33:40 +0800 Subject: [PATCH 231/365] Import with signals from networkx --- pygsp/graphs/graph.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index eabdc221..481ae9d1 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -238,13 +238,15 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): return g_gt @classmethod - def from_networkx(cls, graph_nx): + def from_networkx(cls, graph_nx, singals_names = []): r"""Build a graph from a Networkx object Parameters ---------- graph_nx : Graph A netowrkx instance of a graph + singals_names : list[String] + List of signal names to import from Returns ------- @@ -252,8 +254,15 @@ def from_networkx(cls, graph_nx): """ import networkx as nx - A = nx.to_scipy_sparse_matrix(graph_nx) + nodelist = graph_nx.nodes() + A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) + for s_name in singals_names: + s_dict = nx.get_node_attributes(graph_nx, s_name) + if len(s_dict.keys()) == 0: + raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) + s_value = np.array([s_dict[n] for n in nodelist]) #force the order to be same as for the agency matrix + G.set_signal(s_value, s_name) return G @classmethod From 446bb5b89ebc0405106b7adaca6f3bec267cb25a Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:34:24 +0800 Subject: [PATCH 232/365] graphtool import fix bug when a edge property not present --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 481ae9d1..16f7acbe 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -298,7 +298,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(len(g.edges)) + edge_weight = np.ones(len(graph_gt.edges())) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): From 3e2c295d9ccf9bc9fef7e3446abfd5fea9a24d70 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 15 Sep 2018 15:53:10 +0800 Subject: [PATCH 233/365] Import signal from graph_tool --- pygsp/graphs/graph.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 16f7acbe..aebb17d5 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -246,7 +246,7 @@ def from_networkx(cls, graph_nx, singals_names = []): graph_nx : Graph A netowrkx instance of a graph singals_names : list[String] - List of signal names to import from + List of signals names to import from the networkx graph Returns ------- @@ -266,7 +266,7 @@ def from_networkx(cls, graph_nx, singals_names = []): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals_names = []): r"""Build a graph from a graph tool object. Parameters @@ -279,7 +279,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. - + singals_names : list[String] or 'all' + List of signals names to import from the graph_tool graph or if set to 'all' import all signal present + in the graph Returns ------- g : :class:`~pygsp.graphs.Graph` @@ -305,7 +307,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] - return cls(W) + g = cls(W) + #Adding signals + if singals_names == 'all': + singals_names == graph_gt.vertex_properties.keys() + for s_name in singals_names: + s = np.array([graph_gt.vertex_properties[v] for v in graph_gt.vertices()]) + g.set_signal(s, s_name) + return g @classmethod def load(cls, path, fmt='auto', lib='networkx'): From 8f6c31dffe87058676a48fe5a8fe908e9029496d Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 00:38:18 +0800 Subject: [PATCH 234/365] Fix some typos that were not covered by tests --- pygsp/graphs/graph.py | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index aebb17d5..6f9b6276 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -240,6 +240,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): @classmethod def from_networkx(cls, graph_nx, singals_names = []): r"""Build a graph from a Networkx object + The nodes are ordered according to methode `nodes()` from networkx Parameters ---------- @@ -275,7 +276,11 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals Graph tool object edge_prop_name : string Name of the `property `_ - to be loaded as weight for the graph + to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. + On the other hand if the property is found but not set for a specific edge the weight of zero will be set + therefore for single edge this will result in a none existing edge. If you want to set to a default value please + use `set_value`_ + from the graph_tool object. aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. @@ -300,7 +305,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(len(graph_gt.edges())) + edge_weight = np.ones(graph_gt.edge_index_range) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): @@ -310,10 +315,13 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals g = cls(W) #Adding signals if singals_names == 'all': - singals_names == graph_gt.vertex_properties.keys() + singals_names = graph_gt.vertex_properties.keys() for s_name in singals_names: - s = np.array([graph_gt.vertex_properties[v] for v in graph_gt.vertices()]) - g.set_signal(s, s_name) + if s_name in graph_gt.vertex_properties.keys(): + s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) + g.set_signal(s, s_name) + else: + warnings.warn("{} was not found in the graph_tool graph".format(s_name)) return g @classmethod @@ -340,7 +348,10 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - exec('import ' + lib) + if lib == 'networkx': + import networkx + if lib == 'graph_tool': + import graph_tool err = NotImplementedError('{} can not be load with {}. \ Try another background library'.format(fmt, lib)) @@ -348,16 +359,16 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'gml': if lib == 'networkx': g = networkx.read_gml(path) - return from_networkx(g) + return cls.from_networkx(g) if lib == 'graph_tool': g = graph_tool.load_graph(path, fmt=fmt) - return from_graphtool(g) + return cls.from_graphtool(g) raise err if fmt in ['gpickle', 'p', 'pkl', 'pickle']: if lib == 'networkx': g = networkx.read_gpickle(path) - return from_networkx(g) + return cls.from_networkx(g) raise err raise NotImplementedError('the format {} is not suported'.format(fmt)) @@ -383,15 +394,20 @@ def save(self, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - exec('import ' + lib) + if lib == 'networkx': + import networkx + if lib == 'graph_tool': + import graph_tool + err = NotImplementedError('{} can not be save with {}. \ + Try another background library'.format(fmt, lib)) if fmt == 'gml': if lib == 'networkx': - g = to_networkx() + g = self.to_networkx() networkx.write_gml(g, path) return if lib == 'graph_tool': - g = to_graphtool() + g = self.to_graphtool() g.save(path, fmt=fmt) raise err From 5b6987b2574f5d517b3b20e27fa95ff30753a03b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 00:38:57 +0800 Subject: [PATCH 235/365] Move tests for import and export into a new file --- pygsp/tests/test_graphs.py | 99 ----------------- pygsp/tests/test_import_export.py | 170 ++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 99 deletions(-) create mode 100644 pygsp/tests/test_import_export.py diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ec2e45ea..502956a7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -665,104 +665,5 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) - def test_grid2d_diagonals(self): - value = 0.5 - G = graphs.Grid2d(6, 7, diagonal=value) - self.assertEqual(G.W[2, 8], value) - self.assertEqual(G.W[9, 1], value) - self.assertEqual(G.W[9, 3], value) - self.assertEqual(G.W[2, 14], 0.0) - self.assertEqual(G.W[17, 1], 0.0) - self.assertEqual(G.W[9, 16], 1.0) - self.assertEqual(G.W[20, 27], 1.0) - -suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) - - -class TestImportExport(unittest.TestCase): - - def test_networkx_export_import(self): - # Export to networkx and reimport to PyGSP - - # Exporting the Bunny graph - g = graphs.Bunny() - g_nx = g.to_networkx() - g2 = graphs.Graph.from_networkx(g_nx) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - def test_networkx_import_export(self): - # Import from networkx then export to networkx again - g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph - g = graphs.Graph.from_networkx(g_nx).to_networkx() - - np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), - nx.adjacency_matrix(g).todense()) - - @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') - def test_graphtool_export_import(self): - # Export to graph tool and reimport to PyGSP directly - # The exported graph is a simple one without an associated Signal - g = graphs.Bunny() - g_gt = g.to_graphtool() - g2 = graphs.Graph.from_graphtool(g_gt) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') - def test_graphtool_multiedge_import(self): - # Manualy create a graph with multiple edges - g_gt = gt.Graph() - g_gt.add_vertex(n=10) - # connect edge (3,6) three times - for i in range(3): - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g = graphs.Graph.from_graphtool(g_gt) - self.assertEqual(g.W[3, 6], 3.0) - - eprop_double = g_gt.new_edge_property("double") - - # Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 - e = g_gt.edge(3, 6, all_edges=True) - eprop_double[e[0]] = 8.0 - eprop_double[e[1]] = 1.0 - - g_gt.edge_properties["weight"] = eprop_double - g3 = graphs.Graph.from_graphtool(g_gt) - self.assertEqual(g3.W[3, 6], 9.0) - - @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') - def test_graphtool_import_export(self): - import graph_tool as gt - bunny = graphs.Bunny() - gt_bunny = bunny.to_graphtool() - new_bunny = graphs.Graph.from_graphtool(gt_bunny) - np.testing.assert_array_equal(bunny.W.todense(),new_bunny.W.todense()) - - #create a random graphTool graph - g = gt.Graph() - g.add_vertex(100) - # insert some random links - eprop_double = g.new_edge_property("double") - for s, t in zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100)): - g.add_edge(g.vertex(s), g.vertex(t)) - - for e in g.edges(): - eprop_double[e] = random.random() - g.edge_properties["weight"] = eprop_double - # this assigns random values to the vertex properties (this is a signal) - vprop_double = g.new_vertex_property("double") - vprop_double.get_array()[:] = np.random.random(g.num_vertices()) - g.vertex_properties["signal"] = vprop_double - - new_g = graphs.Graph.from_graphtool(g).to_graphtool() - key = lambda e: str(e.source()) + ":" + str(e.target()) - assert len([e for e in g.edges()]) == len([e for e in new_g.edges()]),\ - "the number of edge does not correspond" - #TODO check if in graph tool its normal to have multiple edges between two vertex - for e1,e2 in zip(sorted(g.edges(), key= key), sorted(new_g.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() - for v1, v2 in zip(g.vertices(), new_g.vertices()): - assert v1 == v2 suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_import_export.py b/pygsp/tests/test_import_export.py new file mode 100644 index 00000000..fb5765eb --- /dev/null +++ b/pygsp/tests/test_import_export.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- + +""" +Test suite for the Import and Export functionality inside the graphs module of the pygsp package. + +""" + +import unittest + +import numpy as np +import networkx as nx +import graph_tool as gt +import random + +from pygsp import graphs + +class TestCase(unittest.TestCase): + + def test_networkx_export_import(self): + #Export to networkx and reimport to PyGSP + + #Exporting the Bunny graph + g = graphs.Bunny() + g_nx = g.to_networkx() + g2 = graphs.Graph.from_networkx(g_nx) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + def test_networkx_import_export(self): + #Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + g = graphs.Graph.from_networkx(g_nx).to_networkx() + + assert nx.is_isomorphic(g_nx, g) + np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), + nx.adjacency_matrix(g).todense()) + + def test_graphtool_export_import(self): + #Export to graph tool and reimport to PyGSP directly + #The exported graph is a simple one without an associated Signal + g = graphs.Bunny() + g_gt = g.to_graphtool() + g2 = graphs.Graph.from_graphtool(g_gt) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + + def test_graphtool_multiedge_import(self): + #Manualy create a graph with multiple edges + g_gt = gt.Graph() + g_gt.add_vertex(10) + #connect edge (3,6) three times + for i in range(3): + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g = graphs.Graph.from_graphtool(g_gt) + assert g.W[3,6] == 3.0 + + #test custom aggregator function + g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g2.W[3,6] == 1.0 + + eprop_double = g_gt.new_edge_property("double") + + #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3,6, all_edges=True) + eprop_double[e[0]] = 8.0 + eprop_double[e[1]] = 1.0 + + g_gt.edge_properties["weight"] = eprop_double + g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g3.W[3,6] == 3.0 + + def test_graphtool_import_export(self): + # Import to PyGSP and export again to graph tool directly + # create a random graphTool graph that does not contain multiple edges and no signal + g_gt = gt.Graph() + g_gt.add_vertex(100) + + # insert single random links + eprop_double = g_gt.new_edge_property("double") + for s, t in set(zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100))): + g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + + for e in g_gt.edges(): + eprop_double[e] = random.random() + g_gt.edge_properties["weight"] = eprop_double + + g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + + assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ + "the number of edge does not correspond" + + key = lambda e: str(e.source()) + ":" + str(e.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + assert v1 == v2 + + def test_networkx_singal_export(self): + logo = graphs.Logo() + s = np.random.random(logo.N) + s2 = np.random.random(logo.N) + logo.set_signal(s, "signal1") + logo.set_signal(s2, "signal2") + logo_nx = logo.to_networkx() + for i in range(50): + # Randomly check the signal of 50 nodes to see if they are the same + rd_node = np.random.randint(logo.N) + assert logo_nx.node[rd_node]["signal1"] == s[rd_node] + assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + + def test_graphtool_signal_export(self): + g = graphs.Logo() + s = np.random.random(g.N) + s2 = np.random.random(g.N) + g.set_signal(s, "signal1") + g.set_signal(s2, "signal2") + g_gt = g.to_graphtool() + #Check the signals on all nodes + for i, v in enumerate(g_gt.vertices()): + assert g_gt.vertex_properties["signal1"][v] == s[i] + assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): + g_gt = gt.Graph() + g_gt.add_vertex(10) + + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) + + vprop_double = g_gt.new_vertex_property("double") + + vprop_double[g_gt.vertex(0)] = 5 + vprop_double[g_gt.vertex(1)] = -3 + vprop_double[g_gt.vertex(2)] = 2.4 + + g_gt.vertex_properties["signal"] = vprop_double + g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + assert g.signals["signal"][0] == 5.0 + assert g.signals["signal"][1] == -3.0 + assert g.signals["signal"][2] == 2.4 + + def test_networkx_singal_import(self): + g_nx = nx.Graph() + g_nx.add_edge(3,4) + g_nx.add_edge(2,4) + g_nx.add_edge(3,5) + print(list(g_nx.node)[0]) + dic_signal = { + 2 : 4.0, + 3 : 5.0, + 4 : 3.3, + 5 : 2.3 + } + + nx.set_node_attributes(g_nx, dic_signal, "signal1") + g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + + nodes_mapping = list(g_nx.node) + for i in range(len(nodes_mapping)): + assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + + + def test_save_load(self): + g = graphs.Bunny() + g.save("bunny.gml") + g2 = graphs.Graph.load("bunny.gml") + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) \ No newline at end of file From a867b58cf460002c5556a58c97b29d78db069a88 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 19 Sep 2018 22:08:04 +0800 Subject: [PATCH 236/365] Cleaning code and correct the indentation for of the doc --- pygsp/graphs/graph.py | 110 ++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6f9b6276..bfb4a76c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -225,7 +225,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): """ ##from graph_tool.all import * import graph_tool - g_gt = graph_tool.Graph(directed=directed) #TODO check for undirected graph + g_gt = graph_tool.Graph(directed=directed) nonzero = self.W.nonzero() g_gt.add_edge_list(np.transpose(nonzero)) edge_weight = g_gt.new_edge_property('double') @@ -253,16 +253,18 @@ def from_networkx(cls, graph_nx, singals_names = []): ------- g : :class:`~pygsp.graphs.Graph` """ - import networkx as nx + #keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) + #Adding the signals for s_name in singals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - s_value = np.array([s_dict[n] for n in nodelist]) #force the order to be same as for the agency matrix + #The signal is set to zero for node not present in the networkx signal + s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) G.set_signal(s_value, s_name) return G @@ -279,7 +281,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please - use `set_value`_ + use `set_value `_ from the graph_tool object. aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the @@ -287,6 +289,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals singals_names : list[String] or 'all' List of signals names to import from the graph_tool graph or if set to 'all' import all signal present in the graph + Returns ------- g : :class:`~pygsp.graphs.Graph` @@ -294,7 +297,6 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals """ nb_vertex = len(graph_gt.get_vertices()) - edge_weight = np.ones(nb_vertex) W = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -305,7 +307,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals else: warnings.warn("""{} property not found in the graph, \ weights of 1 for the edges are set""".format(edge_prop_name)) - edge_weight = np.ones(graph_gt.edge_index_range) + edge_weight = np.ones(nb_vertex) + # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): @@ -313,6 +316,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals for e in merged_edge_weight: W[e[0], e[1]] = e[2] g = cls(W) + #Adding signals if singals_names == 'all': singals_names = graph_gt.vertex_properties.keys() @@ -331,14 +335,14 @@ def load(cls, path, fmt='auto', lib='networkx'): Parameters ---------- - path : String - Where the file is located on the disk. - fmt : String - Format in which the graph is encoded. Currently supported format are: - GML, gpickle. - lib : String - Python library used in background to load the graph. - Supported library are networkx and graph_tool + path : String + Where the file is located on the disk. + fmt : String + Format in which the graph is encoded. Currently supported format are: + GML and gpickle. + lib : String + Python library used in background to load the graph. + Supported library are networkx and graph_tool Returns ------- @@ -348,29 +352,23 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - if lib == 'networkx': - import networkx - if lib == 'graph_tool': - import graph_tool - err = NotImplementedError('{} can not be load with {}. \ - Try another background library'.format(fmt, lib)) + Try another background library'.format(fmt, lib)) - if fmt == 'gml': - if lib == 'networkx': + if lib == 'networkx': + import networkx + if fmt == 'gml': g = networkx.read_gml(path) return cls.from_networkx(g) - if lib == 'graph_tool': - g = graph_tool.load_graph(path, fmt=fmt) - return cls.from_graphtool(g) - raise err - - if fmt in ['gpickle', 'p', 'pkl', 'pickle']: - if lib == 'networkx': + if fmt in ['gpickle', 'p', 'pkl', 'pickle']: g = networkx.read_gpickle(path) return cls.from_networkx(g) raise err - + if lib == 'graph_tool': + import graph_tool + g = graph_tool.load_graph(path, fmt=fmt) + return cls.from_graphtool(g) + raise NotImplementedError('the format {} is not suported'.format(fmt)) def save(self, path, fmt='auto', lib='networkx'): @@ -378,39 +376,37 @@ def save(self, path, fmt='auto', lib='networkx'): Parameters ---------- - path : String - Where to save file on the disk. - fmt : String - Format in which the graph will be encoded. The format is guessed from - the `path` extention when fmt is set to 'auto' - Currently supported format are: - GML, gpickle. - lib : String - Python library used in background to save the graph. - Supported library are networkx and graph_tool - - + path : String + Where to save file on the disk. + fmt : String + Format in which the graph will be encoded. The format is guessed from + the `path` extention when fmt is set to 'auto' + Currently supported format are: + GML and gpickle. + lib : String + Python library used in background to save the graph. + Supported library are networkx and graph_tool """ if fmt == 'auto': fmt = path.split('.')[-1] - if lib == 'networkx': - import networkx - if lib == 'graph_tool': - import graph_tool - err = NotImplementedError('{} can not be save with {}. \ Try another background library'.format(fmt, lib)) - if fmt == 'gml': - if lib == 'networkx': + + if lib == 'networkx': + import networkx + if fmt == 'gml': g = self.to_networkx() networkx.write_gml(g, path) return - if lib == 'graph_tool': - g = self.to_graphtool() - g.save(path, fmt=fmt) raise err - + + if lib == 'graph_tool': + import graph_tool + g = self.to_graphtool() + g.save(path, fmt=fmt) + return + raise NotImplementedError('the format {} is not suported'.format(fmt)) def set_signal(self, signal, signal_name): @@ -419,10 +415,10 @@ def set_signal(self, signal, signal_name): Parameters ---------- - signal : numpy.array - An array maping from node to his value. For example the value of the singal at node i is signal[i] - signal_name : String - Name associated to the signal. + signal : numpy.array + An array maping from node to his value. For example the value of the singal at node i is signal[i] + signal_name : String + Name associated to the signal. """ assert len(signal) == self.N, "A value must be attached to every vertex in the graph" self.signals[signal_name] = np.array(signal) From 9bd7af36d15a17b13a09df65eba4055a1226da21 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 23 Sep 2018 14:20:07 +0800 Subject: [PATCH 237/365] Adding test for import export --- pygsp/tests/test_graphs.py | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 502956a7..fd25c26a 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -667,3 +667,158 @@ def test_grid2dimgpatches(self): suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) + +class TestCaseImportExport(unittest.TestCase): + + def test_networkx_export_import(self): + #Export to networkx and reimport to PyGSP + + #Exporting the Bunny graph + g = graphs.Bunny() + g_nx = g.to_networkx() + g2 = graphs.Graph.from_networkx(g_nx) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + def test_networkx_import_export(self): + #Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + g = graphs.Graph.from_networkx(g_nx).to_networkx() + + assert nx.is_isomorphic(g_nx, g) + np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), + nx.adjacency_matrix(g).todense()) + + def test_graphtool_export_import(self): + #Export to graph tool and reimport to PyGSP directly + #The exported graph is a simple one without an associated Signal + g = graphs.Bunny() + g_gt = g.to_graphtool() + g2 = graphs.Graph.from_graphtool(g_gt) + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + + + def test_graphtool_multiedge_import(self): + #Manualy create a graph with multiple edges + g_gt = gt.Graph() + g_gt.add_vertex(10) + #connect edge (3,6) three times + for i in range(3): + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g = graphs.Graph.from_graphtool(g_gt) + assert g.W[3,6] == 3.0 + + #test custom aggregator function + g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g2.W[3,6] == 1.0 + + eprop_double = g_gt.new_edge_property("double") + + #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3,6, all_edges=True) + eprop_double[e[0]] = 8.0 + eprop_double[e[1]] = 1.0 + + g_gt.edge_properties["weight"] = eprop_double + g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) + assert g3.W[3,6] == 3.0 + + def test_graphtool_import_export(self): + # Import to PyGSP and export again to graph tool directly + # create a random graphTool graph that does not contain multiple edges and no signal + g_gt = gt.Graph() + g_gt.add_vertex(100) + + # insert single random links + eprop_double = g_gt.new_edge_property("double") + for s, t in set(zip(np.random.randint(0, 100, 100), + np.random.randint(0, 100, 100))): + g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + + for e in g_gt.edges(): + eprop_double[e] = random.random() + g_gt.edge_properties["weight"] = eprop_double + + g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + + assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ + "the number of edge does not correspond" + + key = lambda e: str(e.source()) + ":" + str(e.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + assert e1.source() == e2.source() + assert e1.target() == e2.target() + for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + assert v1 == v2 + + def test_networkx_singal_export(self): + logo = graphs.Logo() + s = np.random.random(logo.N) + s2 = np.random.random(logo.N) + logo.set_signal(s, "signal1") + logo.set_signal(s2, "signal2") + logo_nx = logo.to_networkx() + for i in range(50): + # Randomly check the signal of 50 nodes to see if they are the same + rd_node = np.random.randint(logo.N) + assert logo_nx.node[rd_node]["signal1"] == s[rd_node] + assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + + def test_graphtool_signal_export(self): + g = graphs.Logo() + s = np.random.random(g.N) + s2 = np.random.random(g.N) + g.set_signal(s, "signal1") + g.set_signal(s2, "signal2") + g_gt = g.to_graphtool() + #Check the signals on all nodes + for i, v in enumerate(g_gt.vertices()): + assert g_gt.vertex_properties["signal1"][v] == s[i] + assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): + g_gt = gt.Graph() + g_gt.add_vertex(10) + + g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) + g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) + + vprop_double = g_gt.new_vertex_property("double") + + vprop_double[g_gt.vertex(0)] = 5 + vprop_double[g_gt.vertex(1)] = -3 + vprop_double[g_gt.vertex(2)] = 2.4 + + g_gt.vertex_properties["signal"] = vprop_double + g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + assert g.signals["signal"][0] == 5.0 + assert g.signals["signal"][1] == -3.0 + assert g.signals["signal"][2] == 2.4 + + def test_networkx_singal_import(self): + g_nx = nx.Graph() + g_nx.add_edge(3,4) + g_nx.add_edge(2,4) + g_nx.add_edge(3,5) + print(list(g_nx.node)[0]) + dic_signal = { + 2 : 4.0, + 3 : 5.0, + 4 : 3.3, + 5 : 2.3 + } + + nx.set_node_attributes(g_nx, dic_signal, "signal1") + g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + + nodes_mapping = list(g_nx.node) + for i in range(len(nodes_mapping)): + assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + + + def test_save_load(self): + g = graphs.Bunny() + g.save("bunny.gml") + g2 = graphs.Graph.load("bunny.gml") + np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 33bdba68a4821fe1315dfb4bdfcd85fc9d9269a2 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 23 Sep 2018 15:17:46 +0800 Subject: [PATCH 238/365] remove test import file as it is now in the test_graph file --- pygsp/tests/test_import_export.py | 170 ------------------------------ 1 file changed, 170 deletions(-) delete mode 100644 pygsp/tests/test_import_export.py diff --git a/pygsp/tests/test_import_export.py b/pygsp/tests/test_import_export.py deleted file mode 100644 index fb5765eb..00000000 --- a/pygsp/tests/test_import_export.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Test suite for the Import and Export functionality inside the graphs module of the pygsp package. - -""" - -import unittest - -import numpy as np -import networkx as nx -import graph_tool as gt -import random - -from pygsp import graphs - -class TestCase(unittest.TestCase): - - def test_networkx_export_import(self): - #Export to networkx and reimport to PyGSP - - #Exporting the Bunny graph - g = graphs.Bunny() - g_nx = g.to_networkx() - g2 = graphs.Graph.from_networkx(g_nx) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - def test_networkx_import_export(self): - #Import from networkx then export to networkx again - g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph - g = graphs.Graph.from_networkx(g_nx).to_networkx() - - assert nx.is_isomorphic(g_nx, g) - np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), - nx.adjacency_matrix(g).todense()) - - def test_graphtool_export_import(self): - #Export to graph tool and reimport to PyGSP directly - #The exported graph is a simple one without an associated Signal - g = graphs.Bunny() - g_gt = g.to_graphtool() - g2 = graphs.Graph.from_graphtool(g_gt) - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - - - def test_graphtool_multiedge_import(self): - #Manualy create a graph with multiple edges - g_gt = gt.Graph() - g_gt.add_vertex(10) - #connect edge (3,6) three times - for i in range(3): - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3,6] == 3.0 - - #test custom aggregator function - g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3,6] == 1.0 - - eprop_double = g_gt.new_edge_property("double") - - #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 - e = g_gt.edge(3,6, all_edges=True) - eprop_double[e[0]] = 8.0 - eprop_double[e[1]] = 1.0 - - g_gt.edge_properties["weight"] = eprop_double - g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3,6] == 3.0 - - def test_graphtool_import_export(self): - # Import to PyGSP and export again to graph tool directly - # create a random graphTool graph that does not contain multiple edges and no signal - g_gt = gt.Graph() - g_gt.add_vertex(100) - - # insert single random links - eprop_double = g_gt.new_edge_property("double") - for s, t in set(zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100))): - g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) - - for e in g_gt.edges(): - eprop_double[e] = random.random() - g_gt.edge_properties["weight"] = eprop_double - - g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() - - assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ - "the number of edge does not correspond" - - key = lambda e: str(e.source()) + ":" + str(e.target()) - for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() - for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): - assert v1 == v2 - - def test_networkx_singal_export(self): - logo = graphs.Logo() - s = np.random.random(logo.N) - s2 = np.random.random(logo.N) - logo.set_signal(s, "signal1") - logo.set_signal(s2, "signal2") - logo_nx = logo.to_networkx() - for i in range(50): - # Randomly check the signal of 50 nodes to see if they are the same - rd_node = np.random.randint(logo.N) - assert logo_nx.node[rd_node]["signal1"] == s[rd_node] - assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] - - def test_graphtool_signal_export(self): - g = graphs.Logo() - s = np.random.random(g.N) - s2 = np.random.random(g.N) - g.set_signal(s, "signal1") - g.set_signal(s2, "signal2") - g_gt = g.to_graphtool() - #Check the signals on all nodes - for i, v in enumerate(g_gt.vertices()): - assert g_gt.vertex_properties["signal1"][v] == s[i] - assert g_gt.vertex_properties["signal2"][v] == s2[i] - def test_graphtool_signal_import(self): - g_gt = gt.Graph() - g_gt.add_vertex(10) - - g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(4), g_gt.vertex(6)) - g_gt.add_edge(g_gt.vertex(7), g_gt.vertex(2)) - - vprop_double = g_gt.new_vertex_property("double") - - vprop_double[g_gt.vertex(0)] = 5 - vprop_double[g_gt.vertex(1)] = -3 - vprop_double[g_gt.vertex(2)] = 2.4 - - g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) - assert g.signals["signal"][0] == 5.0 - assert g.signals["signal"][1] == -3.0 - assert g.signals["signal"][2] == 2.4 - - def test_networkx_singal_import(self): - g_nx = nx.Graph() - g_nx.add_edge(3,4) - g_nx.add_edge(2,4) - g_nx.add_edge(3,5) - print(list(g_nx.node)[0]) - dic_signal = { - 2 : 4.0, - 3 : 5.0, - 4 : 3.3, - 5 : 2.3 - } - - nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) - - nodes_mapping = list(g_nx.node) - for i in range(len(nodes_mapping)): - assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] - - - def test_save_load(self): - g = graphs.Bunny() - g.save("bunny.gml") - g2 = graphs.Graph.load("bunny.gml") - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - -suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) \ No newline at end of file From 98f92c6fd64f3ae2baf09186c189d983d5482971 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 14:31:01 +0800 Subject: [PATCH 239/365] Some PR fixes --- pygsp/graphs/graph.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index bfb4a76c..896fc514 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -409,19 +409,20 @@ def save(self, path, fmt='auto', lib='networkx'): raise NotImplementedError('the format {} is not suported'.format(fmt)) - def set_signal(self, signal, signal_name): + def set_signal(self, signal, name): r""" Add or modify a signal to the graph Parameters ---------- signal : numpy.array - An array maping from node to his value. For example the value of the singal at node i is signal[i] + An array mapping from node to his value. For example the value of the signal at node i is signal[i] signal_name : String Name associated to the signal. """ - assert len(signal) == self.N, "A value must be attached to every vertex in the graph" - self.signals[signal_name] = np.array(signal) + if len(signal) == self.N: + raise ValueError("A value must be attached to every vertex in the graph") + self.signals[name] = np.asarray(signal) def check_weights(self): r"""Check the characteristics of the weights matrix. From d95773115772ded493c28dd9abad75de863d2c7a Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:02:32 +0800 Subject: [PATCH 240/365] Fix test borken --- pygsp/graphs/graph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 896fc514..5838905e 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -305,8 +305,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() else: - warnings.warn("""{} property not found in the graph, \ - weights of 1 for the edges are set""".format(edge_prop_name)) + warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" + .format(edge_prop_name)) edge_weight = np.ones(nb_vertex) # merging multi-edge @@ -420,7 +420,7 @@ def set_signal(self, signal, name): signal_name : String Name associated to the signal. """ - if len(signal) == self.N: + if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") self.signals[name] = np.asarray(signal) From 7a3c72afedb4eeed08fb2ddf7eb7ca10907d6bb6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:07:31 +0800 Subject: [PATCH 241/365] Correct typo in signal names --- pygsp/graphs/graph.py | 16 ++++++++-------- pygsp/tests/test_graphs.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5838905e..997ae52d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -238,7 +238,7 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): return g_gt @classmethod - def from_networkx(cls, graph_nx, singals_names = []): + def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object The nodes are ordered according to methode `nodes()` from networkx @@ -246,7 +246,7 @@ def from_networkx(cls, graph_nx, singals_names = []): ---------- graph_nx : Graph A netowrkx instance of a graph - singals_names : list[String] + signals_names : list[String] List of signals names to import from the networkx graph Returns @@ -259,7 +259,7 @@ def from_networkx(cls, graph_nx, singals_names = []): A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) #Adding the signals - for s_name in singals_names: + for s_name in signals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) @@ -269,7 +269,7 @@ def from_networkx(cls, graph_nx, singals_names = []): return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals_names = []): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names = []): r"""Build a graph from a graph tool object. Parameters @@ -286,7 +286,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals aggr_fun : function When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the edges. By default the sum is taken. - singals_names : list[String] or 'all' + signals_names : list[String] or 'all' List of signals names to import from the graph_tool graph or if set to 'all' import all signal present in the graph @@ -318,9 +318,9 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, singals g = cls(W) #Adding signals - if singals_names == 'all': - singals_names = graph_gt.vertex_properties.keys() - for s_name in singals_names: + if signals_names == 'all': + signals_names = graph_gt.vertex_properties.keys() + for s_name in signals_names: if s_name in graph_gt.vertex_properties.keys(): s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) g.set_signal(s, s_name) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index fd25c26a..9e2f6f15 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -750,7 +750,7 @@ def test_graphtool_import_export(self): for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): assert v1 == v2 - def test_networkx_singal_export(self): + def test_networkx_signal_export(self): logo = graphs.Logo() s = np.random.random(logo.N) s2 = np.random.random(logo.N) @@ -789,12 +789,12 @@ def test_graphtool_signal_import(self): vprop_double[g_gt.vertex(2)] = 2.4 g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, singals_names=["signal"]) + g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) assert g.signals["signal"][0] == 5.0 assert g.signals["signal"][1] == -3.0 assert g.signals["signal"][2] == 2.4 - def test_networkx_singal_import(self): + def test_networkx_signal_import(self): g_nx = nx.Graph() g_nx.add_edge(3,4) g_nx.add_edge(2,4) @@ -808,7 +808,7 @@ def test_networkx_singal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, singals_names=["signal1"]) + g = graphs.Graph.from_networkx(g_nx, signals_names=["signal1"]) nodes_mapping = list(g_nx.node) for i in range(len(nodes_mapping)): From b6ebf8af1ca2546b8291355527c739a190ce2fcd Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:31:41 +0800 Subject: [PATCH 242/365] Make use of intersphinx --- doc/conf.py | 5 +++++ pygsp/graphs/graph.py | 13 ++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 569c1f04..99c16d8e 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -17,9 +17,14 @@ extensions.append('sphinx.ext.intersphinx') intersphinx_mapping = { 'pyunlocbox': ('https://pyunlocbox.readthedocs.io/en/stable', None), +<<<<<<< HEAD 'numpy': ('https://docs.scipy.org/doc/numpy', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), 'matplotlib': ('https://matplotlib.org', None), +======= + 'networkx': ('https://networkx.github.io/documentation/stable', None), + 'graph_tool': ('https://graph-tool.skewed.de/static/doc', None) +>>>>>>> Make use of intersphinx } extensions.append('numpydoc') diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 997ae52d..13f8f26b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -196,7 +196,7 @@ def to_networkx(self): Returns ------- - g_nx : `Graph `_ + g_nx : :py:class:`networkx.Graph` """ import networkx as nx g = nx.from_scipy_sparse_matrix(self.W) @@ -221,9 +221,8 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): Returns ------- - g_gt : `Graph `_ + g_gt : :py:class:`graph_tool.Graph` """ - ##from graph_tool.all import * import graph_tool g_gt = graph_tool.Graph(directed=directed) nonzero = self.W.nonzero() @@ -241,11 +240,11 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object The nodes are ordered according to methode `nodes()` from networkx - + Parameters ---------- - graph_nx : Graph - A netowrkx instance of a graph + graph_nx : :py:class:`networkx.Graph` + A networkx instance of a graph signals_names : list[String] List of signals names to import from the networkx graph @@ -274,7 +273,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals Parameters ---------- - graph_gt : Graph + graph_gt : :py:class:`graph_tool.Graph` Graph tool object edge_prop_name : string Name of the `property `_ From 01704a1569b8429486babf6b02b704b72c82898d Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 16:58:39 +0800 Subject: [PATCH 243/365] reorder import, and other PR fix --- pygsp/graphs/graph.py | 39 ++++++++++----------------------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 13f8f26b..10d1dad0 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,14 +1,12 @@ # -*- coding: utf-8 -*- -from __future__ import division - -import os +import warnings +from itertools import groupby from collections import Counter import numpy as np from scipy import sparse -from itertools import groupby -import warnings + from pygsp import utils from .fourier import FourierMixIn from .difference import DifferenceMixIn @@ -157,22 +155,6 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.plotting.update(plotting) self.signals = dict() - # Attributes that are lazily computed. - self._A = None - self._d = None - self._dw = None - self._lmax = None - self._lmax_method = None - self._U = None - self._e = None - self._coherence = None - self._D = None - # self._L = None - - # TODO: what about Laplacian? Lazy as Fourier, or disallow change? - self.lap_type = lap_type - self.compute_laplacian(lap_type) - # TODO: kept for backward compatibility. self.Ne = self.n_edges self.N = self.n_vertices @@ -199,13 +181,14 @@ def to_networkx(self): g_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix(self.W) - for key in self.signals: - dic_signal = { i : self.signals[key][i] for i in range(0, len(self.signals[key]) ) } - nx.set_node_attributes(g, dic_signal, key) + g = nx.from_scipy_sparse_matrix(self.W, + create_using=nx.DiGraph if self.is_directed() else nx.Graph) + for name, signal in self.signals.items(): + signal_dict = {i: signal[i] for i in range(self.n_nodes)} + nx.set_node_attributes(g, signal_dict, name) return g - def to_graphtool(self, edge_prop_name='weight', directed=True): + def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double @@ -216,15 +199,13 @@ def to_graphtool(self, edge_prop_name='weight', directed=True): edge_prop_name : string Name of the `property `_. By default it is set to `weight` - directed : bool - Indicate if the graph is `directed `_ Returns ------- g_gt : :py:class:`graph_tool.Graph` """ import graph_tool - g_gt = graph_tool.Graph(directed=directed) + g_gt = graph_tool.Graph(directed=self.is_directed()) nonzero = self.W.nonzero() g_gt.add_edge_list(np.transpose(nonzero)) edge_weight = g_gt.new_edge_property('double') From 44909f479c03f3483edc1573a7e9e58f69702e42 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 17:48:56 +0800 Subject: [PATCH 244/365] Fix such that test passes --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 10d1dad0..76ece198 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -182,7 +182,7 @@ def to_networkx(self): """ import networkx as nx g = nx.from_scipy_sparse_matrix(self.W, - create_using=nx.DiGraph if self.is_directed() else nx.Graph) + create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(g, signal_dict, name) From 512739cf8ebe6ef77e3ae9a06917d007346dad8c Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:24:34 +0800 Subject: [PATCH 245/365] Use self.get_edge_list --- pygsp/graphs/graph.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 76ece198..af0f89db 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -206,10 +206,9 @@ def to_graphtool(self, edge_prop_name='weight'): """ import graph_tool g_gt = graph_tool.Graph(directed=self.is_directed()) - nonzero = self.W.nonzero() - g_gt.add_edge_list(np.transpose(nonzero)) + g_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) edge_weight = g_gt.new_edge_property('double') - edge_weight.a = np.squeeze(np.array(self.W[nonzero])) + edge_weight.a = self.get_edge_list()[2] g_gt.edge_properties[edge_prop_name] = edge_weight for key in self.signals: vprop_double = g_gt.new_vertex_property("double") @@ -295,6 +294,10 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: W[e[0], e[1]] = e[2] + # When the graph is not directed the opposit edge as to be added too. + if not graph_gt.is_directed(): + for e in merged_edge_weight: + W[e[1], e[0]] = e[2] g = cls(W) #Adding signals @@ -1211,6 +1214,7 @@ def get_edge_list(self): """ if self.is_directed(): +<<<<<<< HEAD W = self.W.tocoo() else: W = sparse.triu(self.W, format='coo') @@ -1218,6 +1222,19 @@ def get_edge_list(self): sources = W.row targets = W.col weights = W.data +======= + v_in, v_out = self.W.nonzero() + weights = self.W[v_in, v_out] + weights = np.asarray(weights).squeeze() + else: + v_in, v_out = sparse.triu(self.W).nonzero() + weights = self.W[v_in, v_out] + weights = np.asarray(weights).squeeze() + + # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) + assert self.Ne == v_in.size == v_out.size == weights.size + return v_in, v_out, weights +>>>>>>> Use self.get_edge_list assert self.n_edges == sources.size == targets.size == weights.size return sources, targets, weights From 077658ce95addbf71b98f68f8f13bf49470dc2b9 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:48:28 +0800 Subject: [PATCH 246/365] Fixes for PR comment --- pygsp/graphs/graph.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index af0f89db..d27d449a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -210,16 +210,16 @@ def to_graphtool(self, edge_prop_name='weight'): edge_weight = g_gt.new_edge_property('double') edge_weight.a = self.get_edge_list()[2] g_gt.edge_properties[edge_prop_name] = edge_weight - for key in self.signals: + for name in self.signals: vprop_double = g_gt.new_vertex_property("double") - vprop_double.get_array()[:] = self.signals[key] - g_gt.vertex_properties[key] = vprop_double + vprop_double.get_array()[:] = self.signals[name] + g_gt.vertex_properties[name] = vprop_double return g_gt @classmethod def from_networkx(cls, graph_nx, signals_names = []): r"""Build a graph from a Networkx object - The nodes are ordered according to methode `nodes()` from networkx + The nodes are ordered according to method `nodes()` from networkx Parameters ---------- @@ -320,9 +320,8 @@ def load(cls, path, fmt='auto', lib='networkx'): ---------- path : String Where the file is located on the disk. - fmt : String - Format in which the graph is encoded. Currently supported format are: - GML and gpickle. + fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} + Format in which the graph is encoded. lib : String Python library used in background to load the graph. Supported library are networkx and graph_tool @@ -335,6 +334,9 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] + if format not in ['graphml', 'gml', 'gexf', 'dot']: + raise ValueError('Unsupported format {}.'.format(fmt)) + err = NotImplementedError('{} can not be load with {}. \ Try another background library'.format(fmt, lib)) From 7be7539ec607432b43a0e07fbf406b682fc07074 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 19:50:59 +0800 Subject: [PATCH 247/365] Bug fix --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d27d449a..6a3d9c22 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -334,7 +334,7 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - if format not in ['graphml', 'gml', 'gexf', 'dot']: + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) err = NotImplementedError('{} can not be load with {}. \ From 7ad99bf9ed8fce81135ea13e7a774d59924d7bed Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 20:48:43 +0800 Subject: [PATCH 248/365] Remove some lint error --- pygsp/graphs/graph.py | 45 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6a3d9c22..562b6673 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -174,15 +174,17 @@ def __repr__(self, limit=None): return '{}({})'.format(self.__class__.__name__, s[:-2]) def to_networkx(self): - r"""Export the graph to an `Networkx `_ object + r"""Export the graph to an `Networkx `_ object Returns ------- g_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix(self.W, - create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) + g = nx.from_scipy_sparse_matrix( + self.W, create_using=nx.DiGraph() + if self.is_directed() else nx.Graph()) + for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(g, signal_dict, name) @@ -190,14 +192,14 @@ def to_networkx(self): def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object - The weights of the graph are stored in a `property maps `_ - of type double + The weights of the graph are stored in a `property maps `_ of type double WARNING: The edges and vertex property will be converted into double type Parameters ---------- - edge_prop_name : string - Name of the `property `_. + edge_prop_name : string + Name of the property in :py:attr:`graph_tool.Graph.edge_properties`. By default it is set to `weight` Returns @@ -217,7 +219,7 @@ def to_graphtool(self, edge_prop_name='weight'): return g_gt @classmethod - def from_networkx(cls, graph_nx, signals_names = []): + def from_networkx(cls, graph_nx, signals_names=[]): r"""Build a graph from a Networkx object The nodes are ordered according to method `nodes()` from networkx @@ -227,36 +229,36 @@ def from_networkx(cls, graph_nx, signals_names = []): A networkx instance of a graph signals_names : list[String] List of signals names to import from the networkx graph - + Returns ------- g : :class:`~pygsp.graphs.Graph` """ import networkx as nx - #keep a consistent order of nodes for the agency matrix and the signal array + # keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) G = cls(A) - #Adding the signals + # Adding the signals for s_name in signals_names: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - #The signal is set to zero for node not present in the networkx signal + # The signal is set to zero for node not present in the networkx signal s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) G.set_signal(s_value, s_name) return G @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names = []): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): r"""Build a graph from a graph tool object. - + Parameters ---------- graph_gt : :py:class:`graph_tool.Graph` Graph tool object edge_prop_name : string - Name of the `property `_ + Name of the `property `_ to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please @@ -273,7 +275,6 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals ------- g : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` - """ nb_vertex = len(graph_gt.get_vertices()) W = np.zeros(shape=(nb_vertex, nb_vertex)) @@ -300,7 +301,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals W[e[1], e[0]] = e[2] g = cls(W) - #Adding signals + # Adding signals if signals_names == 'all': signals_names = graph_gt.vertex_properties.keys() for s_name in signals_names: @@ -313,7 +314,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals @classmethod def load(cls, path, fmt='auto', lib='networkx'): - r"""Load a graph from a file using networkx for import. + r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt Parameters @@ -325,11 +326,10 @@ def load(cls, path, fmt='auto', lib='networkx'): lib : String Python library used in background to load the graph. Supported library are networkx and graph_tool - + Returns ------- g : :class:`~pygsp.graphs.Graph` - """ if fmt == 'auto': fmt = path.split('.')[-1] @@ -358,7 +358,7 @@ def load(cls, path, fmt='auto', lib='networkx'): def save(self, path, fmt='auto', lib='networkx'): r"""Save the graph into a file - + Parameters ---------- path : String @@ -373,7 +373,7 @@ def save(self, path, fmt='auto', lib='networkx'): Supported library are networkx and graph_tool """ if fmt == 'auto': - fmt = path.split('.')[-1] + fmt = path.split('.')[-1] err = NotImplementedError('{} can not be save with {}. \ Try another background library'.format(fmt, lib)) @@ -387,7 +387,6 @@ def save(self, path, fmt='auto', lib='networkx'): raise err if lib == 'graph_tool': - import graph_tool g = self.to_graphtool() g.save(path, fmt=fmt) return From 0e16b42d5b775a01686df5441379ec2d9fc5f811 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 6 Oct 2018 21:02:50 +0800 Subject: [PATCH 249/365] resolve some make lint inssues --- pygsp/tests/test_graphs.py | 52 ++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 9e2f6f15..a64de94b 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -668,20 +668,21 @@ def test_grid2dimgpatches(self): suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) + class TestCaseImportExport(unittest.TestCase): def test_networkx_export_import(self): - #Export to networkx and reimport to PyGSP + # Export to networkx and reimport to PyGSP - #Exporting the Bunny graph + # Exporting the Bunny graph g = graphs.Bunny() g_nx = g.to_networkx() g2 = graphs.Graph.from_networkx(g_nx) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) def test_networkx_import_export(self): - #Import from networkx then export to networkx again - g_nx = nx.gnm_random_graph(100, 50) #Generate a random graph + # Import from networkx then export to networkx again + g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() assert nx.is_isomorphic(g_nx, g) @@ -689,38 +690,37 @@ def test_networkx_import_export(self): nx.adjacency_matrix(g).todense()) def test_graphtool_export_import(self): - #Export to graph tool and reimport to PyGSP directly - #The exported graph is a simple one without an associated Signal + # Export to graph tool and reimport to PyGSP directly + # The exported graph is a simple one without an associated Signal g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) - def test_graphtool_multiedge_import(self): - #Manualy create a graph with multiple edges + # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) - #connect edge (3,6) three times + # connect edge (3,6) three times for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3,6] == 3.0 + assert g.W[3, 6] == 3.0 - #test custom aggregator function + # test custom aggregator function g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3,6] == 1.0 + assert g2.W[3, 6] == 1.0 eprop_double = g_gt.new_edge_property("double") - #Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 - e = g_gt.edge(3,6, all_edges=True) + # Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 + e = g_gt.edge(3, 6, all_edges=True) eprop_double[e[0]] = 8.0 eprop_double[e[1]] = 1.0 g_gt.edge_properties["weight"] = eprop_double g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3,6] == 3.0 + assert g3.W[3, 6] == 3.0 def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly @@ -743,7 +743,8 @@ def test_graphtool_import_export(self): assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ "the number of edge does not correspond" - key = lambda e: str(e.source()) + ":" + str(e.target()) + def key(edge): return str(edge.source()) + ":" + str(edge.target()) + for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): assert e1.source() == e2.source() assert e1.target() == e2.target() @@ -770,10 +771,11 @@ def test_graphtool_signal_export(self): g.set_signal(s, "signal1") g.set_signal(s2, "signal2") g_gt = g.to_graphtool() - #Check the signals on all nodes + # Check the signals on all nodes for i, v in enumerate(g_gt.vertices()): assert g_gt.vertex_properties["signal1"][v] == s[i] assert g_gt.vertex_properties["signal2"][v] == s2[i] + def test_graphtool_signal_import(self): g_gt = gt.Graph() g_gt.add_vertex(10) @@ -796,15 +798,15 @@ def test_graphtool_signal_import(self): def test_networkx_signal_import(self): g_nx = nx.Graph() - g_nx.add_edge(3,4) - g_nx.add_edge(2,4) - g_nx.add_edge(3,5) + g_nx.add_edge(3, 4) + g_nx.add_edge(2, 4) + g_nx.add_edge(3, 5) print(list(g_nx.node)[0]) dic_signal = { - 2 : 4.0, - 3 : 5.0, - 4 : 3.3, - 5 : 2.3 + 2: 4.0, + 3: 5.0, + 4: 3.3, + 5: 2.3 } nx.set_node_attributes(g_nx, dic_signal, "signal1") @@ -814,11 +816,11 @@ def test_networkx_signal_import(self): for i in range(len(nodes_mapping)): assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] - def test_save_load(self): g = graphs.Bunny() g.save("bunny.gml") g2 = graphs.Graph.load("bunny.gml") np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 2a261f412fe42ed41d7a38b47ef3bec8025248d6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 16:52:06 +0800 Subject: [PATCH 250/365] Var renaming + adding weight param to from_networkx --- pygsp/graphs/graph.py | 65 ++++++++++++++++++++------------------ pygsp/tests/test_graphs.py | 2 +- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 562b6673..9db6fa6b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -178,20 +178,21 @@ def to_networkx(self): Returns ------- - g_nx : :py:class:`networkx.Graph` + graph_nx : :py:class:`networkx.Graph` """ import networkx as nx - g = nx.from_scipy_sparse_matrix( + graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() if self.is_directed() else nx.Graph()) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} - nx.set_node_attributes(g, signal_dict, name) - return g + nx.set_node_attributes(graph_nx, signal_dict, name) + return graph_nx def to_graphtool(self, edge_prop_name='weight'): r"""Export the graph to an `Graph tool `_ object + The weights of the graph are stored in a `property maps `_ of type double WARNING: The edges and vertex property will be converted into double type @@ -204,50 +205,54 @@ def to_graphtool(self, edge_prop_name='weight'): Returns ------- - g_gt : :py:class:`graph_tool.Graph` + graph_gt : :py:class:`graph_tool.Graph` """ import graph_tool - g_gt = graph_tool.Graph(directed=self.is_directed()) - g_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) - edge_weight = g_gt.new_edge_property('double') + graph_gt = graph_tool.Graph(directed=self.is_directed()) + graph_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) + edge_weight = graph_gt.new_edge_property('double') edge_weight.a = self.get_edge_list()[2] - g_gt.edge_properties[edge_prop_name] = edge_weight + graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - vprop_double = g_gt.new_vertex_property("double") + vprop_double = graph_gt.new_vertex_property("double") vprop_double.get_array()[:] = self.signals[name] - g_gt.vertex_properties[name] = vprop_double - return g_gt + graph_gt.vertex_properties[name] = vprop_double + return graph_gt @classmethod - def from_networkx(cls, graph_nx, signals_names=[]): + def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): r"""Build a graph from a Networkx object + The nodes are ordered according to method `nodes()` from networkx Parameters ---------- graph_nx : :py:class:`networkx.Graph` A networkx instance of a graph - signals_names : list[String] + signals_name : list[String] List of signals names to import from the networkx graph + weight : (string or None optional (default=’weight’)) + The edge attribute that holds the numerical value used for the edge weight. + If None then all edge weights are 1. Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array nodelist = graph_nx.nodes() - A = nx.to_scipy_sparse_matrix(graph_nx, nodelist) - G = cls(A) + adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) + graph = cls(adjacency) # Adding the signals - for s_name in signals_names: + for s_name in signals_name: s_dict = nx.get_node_attributes(graph_nx, s_name) if len(s_dict.keys()) == 0: raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) # The signal is set to zero for node not present in the networkx signal s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) - G.set_signal(s_value, s_name) - return G + graph.set_signal(s_value, s_name) + return graph @classmethod def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): @@ -273,11 +278,11 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ nb_vertex = len(graph_gt.get_vertices()) - W = np.zeros(shape=(nb_vertex, nb_vertex)) + weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -294,23 +299,23 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: - W[e[0], e[1]] = e[2] + weights[e[0], e[1]] = e[2] # When the graph is not directed the opposit edge as to be added too. if not graph_gt.is_directed(): for e in merged_edge_weight: - W[e[1], e[0]] = e[2] - g = cls(W) + weights[e[1], e[0]] = e[2] + graph = cls(weights) # Adding signals if signals_names == 'all': signals_names = graph_gt.vertex_properties.keys() for s_name in signals_names: if s_name in graph_gt.vertex_properties.keys(): - s = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) - g.set_signal(s, s_name) + signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) + graph.set_signal(signal, s_name) else: warnings.warn("{} was not found in the graph_tool graph".format(s_name)) - return g + return graph @classmethod def load(cls, path, fmt='auto', lib='networkx'): @@ -329,7 +334,7 @@ def load(cls, path, fmt='auto', lib='networkx'): Returns ------- - g : :class:`~pygsp.graphs.Graph` + graph : :class:`~pygsp.graphs.Graph` """ if fmt == 'auto': fmt = path.split('.')[-1] @@ -401,7 +406,7 @@ def set_signal(self, signal, name): ---------- signal : numpy.array An array mapping from node to his value. For example the value of the signal at node i is signal[i] - signal_name : String + name : String Name associated to the signal. """ if len(signal) != self.N: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a64de94b..e3649aa7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -810,7 +810,7 @@ def test_networkx_signal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, signals_names=["signal1"]) + g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) nodes_mapping = list(g_nx.node) for i in range(len(nodes_mapping)): From 5768fbe87ae809556c7eaa4f303d3c4c345cb58a Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 17:20:53 +0800 Subject: [PATCH 251/365] Change assert to self.assertEqual --- pygsp/tests/test_graphs.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index e3649aa7..01ab51cb 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -685,7 +685,7 @@ def test_networkx_import_export(self): g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() - assert nx.is_isomorphic(g_nx, g) + self.assertTrue(nx.is_isomorphic(g_nx, g)) np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) @@ -705,11 +705,11 @@ def test_graphtool_multiedge_import(self): for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) g = graphs.Graph.from_graphtool(g_gt) - assert g.W[3, 6] == 3.0 + self.assertEqual(g.W[3, 6], 3.0) # test custom aggregator function g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g2.W[3, 6] == 1.0 + self.assertEqual(g2.W[3, 6], 1.0) eprop_double = g_gt.new_edge_property("double") @@ -720,7 +720,7 @@ def test_graphtool_multiedge_import(self): g_gt.edge_properties["weight"] = eprop_double g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - assert g3.W[3, 6] == 3.0 + self.assertEqual(g3.W[3, 6], 3.0) def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly @@ -746,10 +746,10 @@ def test_graphtool_import_export(self): def key(edge): return str(edge.source()) + ":" + str(edge.target()) for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): - assert e1.source() == e2.source() - assert e1.target() == e2.target() + self.assertEqual(e1.source(), e2.source()) + self.assertEqual(e1.target(), e2.target()) for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): - assert v1 == v2 + self.assertEqual(v1, v2) def test_networkx_signal_export(self): logo = graphs.Logo() @@ -761,8 +761,8 @@ def test_networkx_signal_export(self): for i in range(50): # Randomly check the signal of 50 nodes to see if they are the same rd_node = np.random.randint(logo.N) - assert logo_nx.node[rd_node]["signal1"] == s[rd_node] - assert logo_nx.node[rd_node]["signal2"] == s2[rd_node] + self.assertEqual(logo_nx.node[rd_node]["signal1"], s[rd_node]) + self.assertEqual(logo_nx.node[rd_node]["signal2"], s2[rd_node]) def test_graphtool_signal_export(self): g = graphs.Logo() @@ -773,8 +773,8 @@ def test_graphtool_signal_export(self): g_gt = g.to_graphtool() # Check the signals on all nodes for i, v in enumerate(g_gt.vertices()): - assert g_gt.vertex_properties["signal1"][v] == s[i] - assert g_gt.vertex_properties["signal2"][v] == s2[i] + self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) + self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) def test_graphtool_signal_import(self): g_gt = gt.Graph() @@ -792,9 +792,9 @@ def test_graphtool_signal_import(self): g_gt.vertex_properties["signal"] = vprop_double g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) - assert g.signals["signal"][0] == 5.0 - assert g.signals["signal"][1] == -3.0 - assert g.signals["signal"][2] == 2.4 + self.assertEqual(g.signals["signal"][0], 5.0) + self.assertEqual(g.signals["signal"][1], -3.0) + self.assertEqual(g.signals["signal"][2], 2.4) def test_networkx_signal_import(self): g_nx = nx.Graph() @@ -812,9 +812,9 @@ def test_networkx_signal_import(self): nx.set_node_attributes(g_nx, dic_signal, "signal1") g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) - nodes_mapping = list(g_nx.node) - for i in range(len(nodes_mapping)): - assert g.signals["signal1"][i] == nx.get_node_attributes(g_nx, "signal1")[nodes_mapping[i]] + for i, node in enumerate(g_nx.node): + self.assertEqual(g.signals["signal1"][i], + nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): g = graphs.Bunny() From ae0766671484c75bba3dc56f7378184893766f38 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 7 Oct 2018 17:56:10 +0800 Subject: [PATCH 252/365] Change one assert --- pygsp/tests/test_graphs.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 01ab51cb..88d48928 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -725,30 +725,30 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - g_gt = gt.Graph() - g_gt.add_vertex(100) + graph_gt = gt.Graph() + graph_gt.add_vertex(100) # insert single random links - eprop_double = g_gt.new_edge_property("double") + eprop_double = graph_gt.new_edge_property("double") for s, t in set(zip(np.random.randint(0, 100, 100), np.random.randint(0, 100, 100))): - g_gt.add_edge(g_gt.vertex(s), g_gt.vertex(t)) + graph_gt.add_edge(graph_gt.vertex(s), graph_gt.vertex(t)) - for e in g_gt.edges(): + for e in graph_gt.edges(): eprop_double[e] = random.random() - g_gt.edge_properties["weight"] = eprop_double + graph_gt.edge_properties["weight"] = eprop_double - g2_gt = graphs.Graph.from_graphtool(g_gt).to_graphtool() + graph2_gt = graphs.Graph.from_graphtool(graph_gt).to_graphtool() - assert len([e for e in g_gt.edges()]) == len([e for e in g2_gt.edges()]), \ - "the number of edge does not correspond" + self.assertEqual(graph_gt.num_edges(), graph2_gt.num_edges(), + "the number of edges does not correspond") def key(edge): return str(edge.source()) + ":" + str(edge.target()) - for e1, e2 in zip(sorted(g_gt.edges(), key=key), sorted(g2_gt.edges(), key=key)): + for e1, e2 in zip(sorted(graph_gt.edges(), key=key), sorted(graph2_gt.edges(), key=key)): self.assertEqual(e1.source(), e2.source()) self.assertEqual(e1.target(), e2.target()) - for v1, v2 in zip(g_gt.vertices(), g2_gt.vertices()): + for v1, v2 in zip(graph_gt.vertices(), graph2_gt.vertices()): self.assertEqual(v1, v2) def test_networkx_signal_export(self): From 6e8c636ad74818e28f84125e978727c9bbc84b4c Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Oct 2018 00:28:38 +0800 Subject: [PATCH 253/365] Setting the seed for random tests --- pygsp/tests/test_graphs.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 88d48928..5b9bebf4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -752,20 +752,20 @@ def key(edge): return str(edge.source()) + ":" + str(edge.target()) self.assertEqual(v1, v2) def test_networkx_signal_export(self): - logo = graphs.Logo() - s = np.random.random(logo.N) - s2 = np.random.random(logo.N) - logo.set_signal(s, "signal1") - logo.set_signal(s2, "signal2") - logo_nx = logo.to_networkx() - for i in range(50): - # Randomly check the signal of 50 nodes to see if they are the same - rd_node = np.random.randint(logo.N) - self.assertEqual(logo_nx.node[rd_node]["signal1"], s[rd_node]) - self.assertEqual(logo_nx.node[rd_node]["signal2"], s2[rd_node]) + graph = graphs.BarabasiAlbert(N=100, seed=42) + np.random.seed(42) + signal1 = np.random.random(graph.N) + signal2 = np.random.random(graph.N) + graph.set_signal(signal1, "signal1") + graph.set_signal(signal2, "signal2") + graph_nx = graph.to_networkx() + for i in range(graph.n_nodes): + self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) + self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) def test_graphtool_signal_export(self): g = graphs.Logo() + np.random.seed(42) s = np.random.random(g.N) s2 = np.random.random(g.N) g.set_signal(s, "signal1") From 35ef0e4b411cba41823035ba0c867000d9c5fa56 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 11:55:56 +0800 Subject: [PATCH 254/365] Save and Load reimplemented --- pygsp/graphs/graph.py | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9db6fa6b..983d4ced 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -342,24 +342,21 @@ def load(cls, path, fmt='auto', lib='networkx'): if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - err = NotImplementedError('{} can not be load with {}. \ - Try another background library'.format(fmt, lib)) - - if lib == 'networkx': - import networkx - if fmt == 'gml': - g = networkx.read_gml(path) - return cls.from_networkx(g) - if fmt in ['gpickle', 'p', 'pkl', 'pickle']: - g = networkx.read_gpickle(path) - return cls.from_networkx(g) - raise err - if lib == 'graph_tool': - import graph_tool - g = graph_tool.load_graph(path, fmt=fmt) - return cls.from_graphtool(g) - - raise NotImplementedError('the format {} is not suported'.format(fmt)) + if fmt in ['graphml', 'gml', 'gexf']: + try: + import networkx as nx + load = getattr(nx, 'read_' + fmt) + return cls.from_networkx(load(path)) + except ModuleNotFoundError: + pass + if fmt in ['graphml', 'gml', 'dot']: + try: + import graph_tool as gt + graph_gt = gt.load_graph(path, fmt=fmt) + return cls.from_graphtool(graph_gt) + except ModuleNotFoundError: + pass + raise ModuleNotFoundError("Please install either networkx or graph_tool") def save(self, path, fmt='auto', lib='networkx'): r"""Save the graph into a file @@ -380,23 +377,26 @@ def save(self, path, fmt='auto', lib='networkx'): if fmt == 'auto': fmt = path.split('.')[-1] - err = NotImplementedError('{} can not be save with {}. \ - Try another background library'.format(fmt, lib)) - - if lib == 'networkx': - import networkx - if fmt == 'gml': - g = self.to_networkx() - networkx.write_gml(g, path) - return - raise err - - if lib == 'graph_tool': - g = self.to_graphtool() - g.save(path, fmt=fmt) - return + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: + raise ValueError('Unsupported format {}.'.format(fmt)) - raise NotImplementedError('the format {} is not suported'.format(fmt)) + if fmt in ['graphml', 'gml', 'gexf']: + try: + import networkx as nx + graph_nx = self.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph_nx, path) + return None + except ModuleNotFoundError: + pass + if fmt in ['graphml', 'gml', 'dot']: + try: + graph_gt = self.to_graphtool() + graph_gt.save(path, fmt=fmt) + return None + except ModuleNotFoundError: + pass + raise ModuleNotFoundError("Please install either networkx or graph_tool") def set_signal(self, signal, name): r""" From fd97f870e63ee208fa17cc425fcdfc05048d41a6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 11:58:42 +0800 Subject: [PATCH 255/365] fix overloading suite error --- pygsp/tests/test_graphs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 5b9bebf4..b6d9855c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -666,7 +666,7 @@ def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) -suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) +suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) class TestCaseImportExport(unittest.TestCase): @@ -823,4 +823,5 @@ def test_save_load(self): np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) -suite = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) +suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) +suite = unittest.TestSuite([suite_graphs, suite_import_export]) From 881833bb0798325d93bedf1e5d472a27aa7a9198 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 12:56:23 +0800 Subject: [PATCH 256/365] Add test for save/load of multiple format + some bug fix load and save to "dot", "graphml" are not working yet --- pygsp/graphs/graph.py | 9 +++++++-- pygsp/tests/test_graphs.py | 16 +++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 983d4ced..5f79cd2f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -281,7 +281,8 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ - nb_vertex = len(graph_gt.get_vertices()) + nb_vertex = graph_gt.num_vertices() + nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() @@ -289,10 +290,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals if edge_prop_name in props_names: prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() + if edge_weight is None: + warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") + edge_weight = np.ones(nb_edges) + else: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" .format(edge_prop_name)) - edge_weight = np.ones(nb_vertex) + edge_weight = np.ones(nb_edges) # merging multi-edge merged_edge_weight = [] diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b6d9855c..25be9102 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -7,10 +7,10 @@ from __future__ import division -import os -import random import sys import unittest +import random +import os import numpy as np import scipy.linalg @@ -818,9 +818,15 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - g.save("bunny.gml") - g2 = graphs.Graph.load("bunny.gml") - np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + tested_fmt = ["gml", "gexf"] #"dot", "graphml" + for fmt in tested_fmt: + g.save("bunny." + fmt) + + for fmt in tested_fmt: + graph_loaded = graphs.Graph.load("bunny." + fmt) + np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + os.remove("bunny." + fmt) + suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 78f855064c28ff5f41c31da0cb7e399713feb0b6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 19:34:41 +0800 Subject: [PATCH 257/365] Add doc for to_networkx + argument to store weight under another name --- pygsp/graphs/graph.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5f79cd2f..012d384e 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -173,9 +173,19 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self): + def to_networkx(self, edge_prop_name='weight'): r"""Export the graph to an `Networkx `_ object + The weight are stored as an edge attribute under named `edge_prop_name` + The signals are stored as node attributes under the same name as define in PyGSP + :func:`~pygsp.graphs.Graph.set_signal`. + + Parameters + ---------- + edge_prop_name : string + Name of edge attribute to store matrix numeric value. + As the attibute edge_attribute in :py:func:`networkx.convert_matrix.from_scipy_sparse_matrix`. + Returns ------- graph_nx : :py:class:`networkx.Graph` @@ -183,7 +193,8 @@ def to_networkx(self): import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() - if self.is_directed() else nx.Graph()) + if self.is_directed() else nx.Graph(), + edge_attribute=edge_prop_name) for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} From fd2517f997dab6f5d645a8ef8511794acaee4b39 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 19:53:05 +0800 Subject: [PATCH 258/365] Fix https://github.com/epfl-lts2/pygsp/pull/32#discussion_r223394071 --- pygsp/graphs/graph.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 012d384e..ee243466 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -220,9 +220,10 @@ def to_graphtool(self, edge_prop_name='weight'): """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) - graph_gt.add_edge_list(np.asarray(self.get_edge_list()[0:2]).T) + v_in, v_out, weights = self.get_edge_list() + graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) edge_weight = graph_gt.new_edge_property('double') - edge_weight.a = self.get_edge_list()[2] + edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: vprop_double = graph_gt.new_vertex_property("double") @@ -1254,7 +1255,7 @@ def get_edge_list(self): weights = np.asarray(weights).squeeze() # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) - assert self.Ne == v_in.size == v_out.size == weights.size + assert self.Ne == v_in.size == v_out.size == weights.size return v_in, v_out, weights >>>>>>> Use self.get_edge_list From 4968cb07d261c5ad7f18a89389b568e71458aca6 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 20:26:36 +0800 Subject: [PATCH 259/365] Auto detect type of attributes when exporting to graphtool --- pygsp/graphs/graph.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ee243466..08b3f68c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -218,15 +218,39 @@ def to_graphtool(self, edge_prop_name='weight'): ------- graph_gt : :py:class:`graph_tool.Graph` """ + + # Encode the numpy types with its correspondence in graph_tool + numpy2gt_type = { + np.bool_: 'bool', + np.int_: 'int', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.float_: 'long double', + np.float16: 'double', + np.float32: 'double', + np.float64: 'long double' + } + import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - edge_weight = graph_gt.new_edge_property('double') + try: + weight_type_str = numpy2gt_type[weights.dtype.type] + except KeyError: + raise ValueError("Type {} for the weights is not supported" + .format(str(weights.dtype))) + edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - vprop_double = graph_gt.new_vertex_property("double") + try: + edge_type_str = numpy2gt_type[weights.dtype.type] + except KeyError: + raise ValueError("Type {} from signal {} is not supported" + .format(str(self.signals[name].dtype), name)) + vprop_double = graph_gt.new_vertex_property(edge_type_str) vprop_double.get_array()[:] = self.signals[name] graph_gt.vertex_properties[name] = vprop_double return graph_gt From 08a5f7c5d3ec06277fa161e3b5d4d9cac822c605 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sat, 13 Oct 2018 21:05:59 +0800 Subject: [PATCH 260/365] Move the dict for type conversion into utils. --- doc/conf.py | 5 +++++ pygsp/graphs/graph.py | 25 +++++-------------------- pygsp/tests/test_graphs.py | 1 - pygsp/utils.py | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 99c16d8e..ad5e460a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -23,8 +23,13 @@ 'matplotlib': ('https://matplotlib.org', None), ======= 'networkx': ('https://networkx.github.io/documentation/stable', None), +<<<<<<< HEAD 'graph_tool': ('https://graph-tool.skewed.de/static/doc', None) >>>>>>> Make use of intersphinx +======= + 'graph_tool': ('https://graph-tool.skewed.de/static/doc', None), + 'numpy': ('https://www.numpy.org/devdocs', None) +>>>>>>> Move the dict for type conversion into utils. } extensions.append('numpydoc') diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 08b3f68c..0dcd3ebd 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -219,35 +219,20 @@ def to_graphtool(self, edge_prop_name='weight'): graph_gt : :py:class:`graph_tool.Graph` """ - # Encode the numpy types with its correspondence in graph_tool - numpy2gt_type = { - np.bool_: 'bool', - np.int_: 'int', - np.int16: 'int16_t', - np.int32: 'int32_t', - np.int64: 'int64_t', - np.float_: 'long double', - np.float16: 'double', - np.float32: 'double', - np.float64: 'long double' - } - import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - try: - weight_type_str = numpy2gt_type[weights.dtype.type] - except KeyError: + weight_type_str = utils.numpy2graph_tool_type(weights.dtype) + if weight_type_str is None: raise ValueError("Type {} for the weights is not supported" .format(str(weights.dtype))) edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights graph_gt.edge_properties[edge_prop_name] = edge_weight for name in self.signals: - try: - edge_type_str = numpy2gt_type[weights.dtype.type] - except KeyError: + edge_type_str = utils.numpy2graph_tool_type(weights.dtype) + if edge_type_str is None: raise ValueError("Type {} from signal {} is not supported" .format(str(self.signals[name].dtype), name)) vprop_double = graph_gt.new_vertex_property(edge_type_str) @@ -257,7 +242,7 @@ def to_graphtool(self, edge_prop_name='weight'): @classmethod def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): - r"""Build a graph from a Networkx object + r"""Build a graph from a Networkx object. The nodes are ordered according to method `nodes()` from networkx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 25be9102..baab76ca 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -801,7 +801,6 @@ def test_networkx_signal_import(self): g_nx.add_edge(3, 4) g_nx.add_edge(2, 4) g_nx.add_edge(3, 5) - print(list(g_nx.node)[0]) dic_signal = { 2: 4.0, 3: 5.0, diff --git a/pygsp/utils.py b/pygsp/utils.py index 94474eb1..03e4cbdb 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -364,3 +364,40 @@ def import_functions(names, src, dst): for name in names: module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) + +def numpy2graph_tool_type(dtype): + r"""Convert from numpy dtype to graph tool types. + + The supported numpy types are: {bool_, int_, int16, int32, int64, + float_, float16, float32, float64} + See graph_tool `doc `_ for more details. + + Parameters + ---------- + dtype : :py:class:`numpy.dtype` + + Returns + ------- + graph_tool_type : string + A string representing the type ready to be use by graph_tool + + """ + # Encode the numpy types with its correspondence in graph_tool + numpy2gt_type = { + np.bool_: 'bool', + np.int_: 'int', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.float_: 'long double', + np.float16: 'double', + np.float32: 'double', + np.float64: 'long double' + } + + try: + graph_tool_type = numpy2gt_type[dtype.type] + except: + graph_tool_type = None + + return graph_tool_type From c56cb0ec20c290e911f7e7c95cfa4a2e796020d7 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 11:23:43 +0800 Subject: [PATCH 261/365] ask for forgiveness rather than permission --- pygsp/graphs/graph.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0dcd3ebd..73f114c3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -308,14 +308,14 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals props_names = graph_gt.edge_properties.keys() - if edge_prop_name in props_names: + try: prop = graph_gt.edge_properties[edge_prop_name] edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) - else: + except KeyError: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" .format(edge_prop_name)) edge_weight = np.ones(nb_edges) From aa8f4ef85d111e410835de2412bba3ef7a8f3781 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 11:55:06 +0800 Subject: [PATCH 262/365] remove edge property name from to_graphtool and to_networkx --- pygsp/graphs/graph.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 73f114c3..3c56f61d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -173,19 +173,13 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self, edge_prop_name='weight'): + def to_networkx(self): r"""Export the graph to an `Networkx `_ object - The weight are stored as an edge attribute under named `edge_prop_name` + The weight are stored as an edge attribute under named `weight` The signals are stored as node attributes under the same name as define in PyGSP :func:`~pygsp.graphs.Graph.set_signal`. - Parameters - ---------- - edge_prop_name : string - Name of edge attribute to store matrix numeric value. - As the attibute edge_attribute in :py:func:`networkx.convert_matrix.from_scipy_sparse_matrix`. - Returns ------- graph_nx : :py:class:`networkx.Graph` @@ -194,31 +188,23 @@ def to_networkx(self, edge_prop_name='weight'): graph_nx = nx.from_scipy_sparse_matrix( self.W, create_using=nx.DiGraph() if self.is_directed() else nx.Graph(), - edge_attribute=edge_prop_name) + edge_attribute='weight') for name, signal in self.signals.items(): signal_dict = {i: signal[i] for i in range(self.n_nodes)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx - def to_graphtool(self, edge_prop_name='weight'): + def to_graphtool(self): r"""Export the graph to an `Graph tool `_ object The weights of the graph are stored in a `property maps `_ of type double - WARNING: The edges and vertex property will be converted into double type - - Parameters - ---------- - edge_prop_name : string - Name of the property in :py:attr:`graph_tool.Graph.edge_properties`. - By default it is set to `weight` + quickstart.html#internal-property-maps>`_ under the name `weight` Returns ------- graph_gt : :py:class:`graph_tool.Graph` """ - import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) v_in, v_out, weights = self.get_edge_list() @@ -229,7 +215,7 @@ def to_graphtool(self, edge_prop_name='weight'): .format(str(weights.dtype))) edge_weight = graph_gt.new_edge_property(weight_type_str) edge_weight.a = weights - graph_gt.edge_properties[edge_prop_name] = edge_weight + graph_gt.edge_properties['weight'] = edge_weight for name in self.signals: edge_type_str = utils.numpy2graph_tool_type(weights.dtype) if edge_type_str is None: From 69bb37a6db5dfaeacd135afb79149d6e42a43cec Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 14 Oct 2018 12:48:04 +0800 Subject: [PATCH 263/365] Import all the signals as asked in https://github.com/epfl-lts2/pygsp/pull/32#discussion_r223418494 --- pygsp/graphs/graph.py | 52 ++++++++++++++++++-------------------- pygsp/tests/test_graphs.py | 5 ++-- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 3c56f61d..c7c0e47b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -227,17 +227,21 @@ def to_graphtool(self): return graph_gt @classmethod - def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): + def from_networkx(cls, graph_nx, weight='weight'): r"""Build a graph from a Networkx object. The nodes are ordered according to method `nodes()` from networkx + When a node attribute is not present for node a value of zero is assign + to the corresponding signal on that node. + + When the networkx graph is an instance of :py:class:`networkx.MultiGraph`, + multiple edge are aggregated by summation. + Parameters ---------- graph_nx : :py:class:`networkx.Graph` A networkx instance of a graph - signals_name : list[String] - List of signals names to import from the networkx graph weight : (string or None optional (default=’weight’)) The edge attribute that holds the numerical value used for the edge weight. If None then all edge weights are 1. @@ -252,17 +256,25 @@ def from_networkx(cls, graph_nx, signals_name=[], weight='weight'): adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) graph = cls(adjacency) # Adding the signals - for s_name in signals_name: - s_dict = nx.get_node_attributes(graph_nx, s_name) - if len(s_dict.keys()) == 0: - raise ValueError("Signal {} is not present in the networkx graph".format(s_name)) - # The signal is set to zero for node not present in the networkx signal - s_value = np.array([s_dict[n] if n in s_dict else 0 for n in nodelist]) - graph.set_signal(s_value, s_name) + signals = dict() + for i, node in enumerate(nodelist): + signals_name = graph_nx.nodes[node].keys() + + # Add signal previously not present in the dict of signal + # Set to zero the value of the signal when not present for a node + # in Networkx + for signal in set(signals_name) - set(signals.keys()): + signals[signal] = np.zeros(len(nodelist)) + + # Set the value of the signal + for signal in signals_name: + signals[signal][i] = graph_nx.nodes[node][signal] + + graph.signals = signals return graph @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals_names=[]): + def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): r"""Build a graph from a graph tool object. Parameters @@ -319,9 +331,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum, signals graph = cls(weights) # Adding signals - if signals_names == 'all': - signals_names = graph_gt.vertex_properties.keys() - for s_name in signals_names: + for s_name in graph_gt.vertex_properties.keys(): if s_name in graph_gt.vertex_properties.keys(): signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) graph.set_signal(signal, s_name) @@ -1232,7 +1242,6 @@ def get_edge_list(self): """ if self.is_directed(): -<<<<<<< HEAD W = self.W.tocoo() else: W = sparse.triu(self.W, format='coo') @@ -1240,19 +1249,6 @@ def get_edge_list(self): sources = W.row targets = W.col weights = W.data -======= - v_in, v_out = self.W.nonzero() - weights = self.W[v_in, v_out] - weights = np.asarray(weights).squeeze() - else: - v_in, v_out = sparse.triu(self.W).nonzero() - weights = self.W[v_in, v_out] - weights = np.asarray(weights).squeeze() - - # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) - assert self.Ne == v_in.size == v_out.size == weights.size - return v_in, v_out, weights ->>>>>>> Use self.get_edge_list assert self.n_edges == sources.size == targets.size == weights.size return sources, targets, weights diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index baab76ca..bd16bfc4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -791,7 +791,7 @@ def test_graphtool_signal_import(self): vprop_double[g_gt.vertex(2)] = 2.4 g_gt.vertex_properties["signal"] = vprop_double - g = graphs.Graph.from_graphtool(g_gt, signals_names=["signal"]) + g = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g.signals["signal"][0], 5.0) self.assertEqual(g.signals["signal"][1], -3.0) self.assertEqual(g.signals["signal"][2], 2.4) @@ -809,7 +809,7 @@ def test_networkx_signal_import(self): } nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx, signals_name=["signal1"]) + g = graphs.Graph.from_networkx(g_nx) for i, node in enumerate(g_nx.node): self.assertEqual(g.signals["signal1"][i], @@ -827,6 +827,5 @@ def test_save_load(self): os.remove("bunny." + fmt) - suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) suite = unittest.TestSuite([suite_graphs, suite_import_export]) From ff8c9ded6f9ee23df302e808801ca6361cf398a3 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 17 Oct 2018 21:37:57 +0800 Subject: [PATCH 264/365] Simplify from_graphtool() --- pygsp/graphs/graph.py | 18 +++++++----------- pygsp/tests/test_graphs.py | 8 ++------ 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index c7c0e47b..f2942770 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -274,26 +274,22 @@ def from_networkx(cls, graph_nx, weight='weight'): return graph @classmethod - def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): + def from_graphtool(cls, graph_gt, weight='weight'): r"""Build a graph from a graph tool object. + When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. + Parameters ---------- graph_gt : :py:class:`graph_tool.Graph` Graph tool object - edge_prop_name : string + weight : string Name of the `property `_ to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. On the other hand if the property is found but not set for a specific edge the weight of zero will be set therefore for single edge this will result in a none existing edge. If you want to set to a default value please use `set_value `_ from the graph_tool object. - aggr_fun : function - When the graph as multiple edge connecting the same two nodes the aggragate function is called to merge the - edges. By default the sum is taken. - signals_names : list[String] or 'all' - List of signals names to import from the graph_tool graph or if set to 'all' import all signal present - in the graph Returns ------- @@ -307,7 +303,7 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): props_names = graph_gt.edge_properties.keys() try: - prop = graph_gt.edge_properties[edge_prop_name] + prop = graph_gt.edge_properties[weight] edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") @@ -315,13 +311,13 @@ def from_graphtool(cls, graph_gt, edge_prop_name='weight', aggr_fun=sum): except KeyError: warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" - .format(edge_prop_name)) + .format(weight)) edge_weight = np.ones(nb_edges) # merging multi-edge merged_edge_weight = [] for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], aggr_fun([edge_weight[e[2]] for e in grp]))) + merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) for e in merged_edge_weight: weights[e[0], e[1]] = e[2] # When the graph is not directed the opposit edge as to be added too. diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index bd16bfc4..e0baeffc 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -707,10 +707,6 @@ def test_graphtool_multiedge_import(self): g = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g.W[3, 6], 3.0) - # test custom aggregator function - g2 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - self.assertEqual(g2.W[3, 6], 1.0) - eprop_double = g_gt.new_edge_property("double") # Set the weight of 2 out of the 3 edges. The last one has a default weight of 0 @@ -719,8 +715,8 @@ def test_graphtool_multiedge_import(self): eprop_double[e[1]] = 1.0 g_gt.edge_properties["weight"] = eprop_double - g3 = graphs.Graph.from_graphtool(g_gt, aggr_fun=np.mean) - self.assertEqual(g3.W[3, 6], 3.0) + g3 = graphs.Graph.from_graphtool(g_gt) + self.assertEqual(g3.W[3, 6], 9.0) def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly From 8023519f9b73220f93fefd79a2d5607e6bd7752f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 17 Oct 2018 21:53:39 +0800 Subject: [PATCH 265/365] Cleaner way of adding the signal --- pygsp/graphs/graph.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index f2942770..dedb3a38 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -327,12 +327,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): graph = cls(weights) # Adding signals - for s_name in graph_gt.vertex_properties.keys(): - if s_name in graph_gt.vertex_properties.keys(): - signal = np.array([graph_gt.vertex_properties[s_name][v] for v in graph_gt.vertices()]) - graph.set_signal(signal, s_name) - else: - warnings.warn("{} was not found in the graph_tool graph".format(s_name)) + for signal_name, signal_gt in graph_gt.vertex_properties.items(): + signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) + graph.set_signal(signal, signal_name) return graph @classmethod From b3f5d5eb8729c55815af023aa7403457a360a1e0 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 27 Dec 2018 23:53:35 +0800 Subject: [PATCH 266/365] Test environement on travis --- .travis.yml | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7c5f0439..bf713de7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,21 +23,14 @@ addons: - libflann-dev install: - - pip install --upgrade --upgrade-strategy eager .[dev] - # Upgrade to test with the latest version of our dependencies. - -before_script: - # As graph-tool cannot be installed by pip, link to the system installation - # from the virtual environment. - # TODO: remove the condition once we drop python 2.7. - - if [[ $(python -c 'import sys; print(sys.version_info[0])') = 3 ]] ; - then ln -s "/usr/lib/python3/dist-packages/graph_tool" $(python -c "import site; print(site.getsitepackages()[0])"); - else ln -s "/usr/lib/python2.7/dist-packages/graph_tool" $(python -c "import site; print(site.getsitepackages()[0])"); fi + - pip install -U --upgrade-strategy eager .[alldeps,test,doc] + # Update dependencies (e.g. numpy formatting changed in v1.14). script: # - make lint - - make test - - make doc +# - make test +# - make doc + - docker run -v `pwd`:/opt/pygsp cgallay/pygsp make test after_success: - coveralls From 95c62a2e9407fd732422ceb62ef35eb2a75067d0 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Sun, 3 Feb 2019 18:54:54 +0800 Subject: [PATCH 267/365] Bug fix --- pygsp/graphs/graph.py | 2 +- pygsp/tests/test_graphs.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index dedb3a38..d78f5dd2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -191,7 +191,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): - signal_dict = {i: signal[i] for i in range(self.n_nodes)} + signal_dict = {i: signal[i] for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index e0baeffc..fe1de0e7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -755,7 +755,7 @@ def test_networkx_signal_export(self): graph.set_signal(signal1, "signal1") graph.set_signal(signal2, "signal2") graph_nx = graph.to_networkx() - for i in range(graph.n_nodes): + for i in range(graph.N): self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) @@ -813,7 +813,7 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - tested_fmt = ["gml", "gexf"] #"dot", "graphml" + tested_fmt = ["gml", "gexf"] # "dot", "graphml" for fmt in tested_fmt: g.save("bunny." + fmt) From bbda5803d2c090379f1122f1c6ba69c30146cb31 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 7 Feb 2019 21:23:24 +0800 Subject: [PATCH 268/365] Add auto selection of save and load backend based on the format of the file. --- pygsp/graphs/graph.py | 84 ++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d78f5dd2..bcd2f7f0 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -333,7 +333,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): return graph @classmethod - def load(cls, path, fmt='auto', lib='networkx'): + def load(cls, path, fmt='auto', backend='networkx'): r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt @@ -343,7 +343,7 @@ def load(cls, path, fmt='auto', lib='networkx'): Where the file is located on the disk. fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} Format in which the graph is encoded. - lib : String + backend : String Python library used in background to load the graph. Supported library are networkx and graph_tool @@ -351,29 +351,36 @@ def load(cls, path, fmt='auto', lib='networkx'): ------- graph : :class:`~pygsp.graphs.Graph` """ + + def load_networkx(saved_path, format): + import networkx as nx + load = getattr(nx, 'read_' + format) + return cls.from_networkx(load(saved_path)) + + def load_graph_tool(saved_path, format): + import graph_tool as gt + graph_gt = gt.load_graph(saved_path, fmt=format) + return cls.from_graphtool(graph_gt) + if fmt == 'auto': fmt = path.split('.')[-1] + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - if fmt in ['graphml', 'gml', 'gexf']: - try: - import networkx as nx - load = getattr(nx, 'read_' + fmt) - return cls.from_networkx(load(path)) - except ModuleNotFoundError: - pass - if fmt in ['graphml', 'gml', 'dot']: - try: - import graph_tool as gt - graph_gt = gt.load_graph(path, fmt=fmt) - return cls.from_graphtool(graph_gt) - except ModuleNotFoundError: - pass - raise ModuleNotFoundError("Please install either networkx or graph_tool") - - def save(self, path, fmt='auto', lib='networkx'): + if backend not in ['networkx', 'graph_tool']: + raise ValueError('Unsupported backend specified {}.'.format(backend)) + + + return locals()['load_' + backend](path, fmt) + + def save(self, path, fmt='auto', backend='auto'): r"""Save the graph into a file Parameters @@ -385,33 +392,36 @@ def save(self, path, fmt='auto', lib='networkx'): the `path` extention when fmt is set to 'auto' Currently supported format are: GML and gpickle. - lib : String + backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool """ + def save_networkx(graph, save_path): + import networkx as nx + graph_nx = graph.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph_nx, save_path) + + def save_graph_tool(graph, save_path): + graph_gt = self.to_graphtool() + graph_gt.save(path, fmt=fmt) + if fmt == 'auto': fmt = path.split('.')[-1] + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + if fmt not in ['graphml', 'gml', 'gexf', 'dot']: raise ValueError('Unsupported format {}.'.format(fmt)) - if fmt in ['graphml', 'gml', 'gexf']: - try: - import networkx as nx - graph_nx = self.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph_nx, path) - return None - except ModuleNotFoundError: - pass - if fmt in ['graphml', 'gml', 'dot']: - try: - graph_gt = self.to_graphtool() - graph_gt.save(path, fmt=fmt) - return None - except ModuleNotFoundError: - pass - raise ModuleNotFoundError("Please install either networkx or graph_tool") + if backend not in ['networkx', 'graph_tool']: + raise ValueError('Unsupported backend specified {}.'.format(backend)) + + locals()['save_' + backend](self, path) def set_signal(self, signal, name): r""" From ea1131622de0900c0fc971d9a57346c19708d554 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 7 Feb 2019 21:27:59 +0800 Subject: [PATCH 269/365] install dependencies for graph-tool on travis --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index bf713de7..6b472238 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,9 +28,8 @@ install: script: # - make lint -# - make test -# - make doc - - docker run -v `pwd`:/opt/pygsp cgallay/pygsp make test + - make test + - make doc after_success: - coveralls From 6f1355de585cc25a13e38b74d945c7ad56c22ac1 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 7 Feb 2019 22:43:01 +0800 Subject: [PATCH 270/365] add system_sit_package true --- doc/conf.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index ad5e460a..569c1f04 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -17,19 +17,9 @@ extensions.append('sphinx.ext.intersphinx') intersphinx_mapping = { 'pyunlocbox': ('https://pyunlocbox.readthedocs.io/en/stable', None), -<<<<<<< HEAD 'numpy': ('https://docs.scipy.org/doc/numpy', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), 'matplotlib': ('https://matplotlib.org', None), -======= - 'networkx': ('https://networkx.github.io/documentation/stable', None), -<<<<<<< HEAD - 'graph_tool': ('https://graph-tool.skewed.de/static/doc', None) ->>>>>>> Make use of intersphinx -======= - 'graph_tool': ('https://graph-tool.skewed.de/static/doc', None), - 'numpy': ('https://www.numpy.org/devdocs', None) ->>>>>>> Move the dict for type conversion into utils. } extensions.append('numpydoc') From 3fbc04c9e508618f87eed76ed33cb2e60cfd10af Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 14:35:18 +0100 Subject: [PATCH 271/365] test for python > 3.5 only --- pygsp/graphs/graph.py | 3 +-- pygsp/tests/test_graphs.py | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index bcd2f7f0..107d7931 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -333,7 +333,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): return graph @classmethod - def load(cls, path, fmt='auto', backend='networkx'): + def load(cls, path, fmt='auto', backend='auto'): r"""Load a graph from a file using networkx for import. The format is guessed from path, or can be specified by fmt @@ -377,7 +377,6 @@ def load_graph_tool(saved_path, format): if backend not in ['networkx', 'graph_tool']: raise ValueError('Unsupported backend specified {}.'.format(backend)) - return locals()['load_' + backend](path, fmt) def save(self, path, fmt='auto', backend='auto'): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index fe1de0e7..19aa9320 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -11,6 +11,7 @@ import unittest import random import os +import sys import numpy as np import scipy.linalg @@ -813,14 +814,15 @@ def test_networkx_signal_import(self): def test_save_load(self): g = graphs.Bunny() - tested_fmt = ["gml", "gexf"] # "dot", "graphml" - for fmt in tested_fmt: - g.save("bunny." + fmt) - - for fmt in tested_fmt: - graph_loaded = graphs.Graph.load("bunny." + fmt) - np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) - os.remove("bunny." + fmt) + tested_fmt = ["gml", "gexf", "graphml"] + if sys.version_info > (3, 5): + for fmt in tested_fmt: + g.save("bunny." + fmt) + + for fmt in tested_fmt: + graph_loaded = graphs.Graph.load("bunny." + fmt) + np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + os.remove("bunny." + fmt) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From d2f8bb9010f7e27310c40ed20bd29cc1a034db8e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 17:24:30 +0100 Subject: [PATCH 272/365] Fix save for graph_tool (dot, gml) --- pygsp/graphs/graph.py | 12 +++++++++++- pygsp/tests/test_graphs.py | 25 ++++++++++++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 107d7931..1b8cf114 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -296,15 +296,24 @@ def from_graphtool(cls, graph_gt, weight='weight'): graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ + import graph_tool as gt nb_vertex = graph_gt.num_vertices() nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) props_names = graph_gt.edge_properties.keys() + if 'vertex_name' in graph_gt.vertex_properties.keys(): + vertex_name = graph_gt.vertex_properties['vertex_name'] + scalar_vertex = graph_gt.new_vertex_property('int') + gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) + # graph_gt.vertex_properties['vertex_name'] = scalar_vertex + graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) try: prop = graph_gt.edge_properties[weight] - edge_weight = prop.get_array() + scalar_prop = graph_gt.new_edge_property('double') + gt.map_property_values(prop, scalar_prop, lambda x: float(x)) + edge_weight = scalar_prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) @@ -394,6 +403,7 @@ def save(self, path, fmt='auto', backend='auto'): backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool + WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. """ def save_networkx(graph, save_path): import networkx as nx diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 19aa9320..835f5e91 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -11,7 +11,6 @@ import unittest import random import os -import sys import numpy as np import scipy.linalg @@ -813,16 +812,32 @@ def test_networkx_signal_import(self): nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): - g = graphs.Bunny() + g = graphs.Sensor(seed=42) tested_fmt = ["gml", "gexf", "graphml"] + filename = "graph." if sys.version_info > (3, 5): for fmt in tested_fmt: - g.save("bunny." + fmt) + g.save(filename + fmt) for fmt in tested_fmt: - graph_loaded = graphs.Graph.load("bunny." + fmt) + graph_loaded = graphs.Graph.load(filename + fmt) np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) - os.remove("bunny." + fmt) + os.remove(filename + fmt) + + fmt = "gml" + + g.save(filename + fmt, backend='graph_tool') + graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') + np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + os.remove(filename + fmt) + + fmt = 'dot' + g = graphs.Sensor(seed=42) + g.save(filename + fmt, backend='graph_tool') + graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') + g = graphs.Sensor(seed=42) + np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + os.remove(filename + fmt) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From f2f895d0476c7886c966219ecb865e8c59f5c54b Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 17:32:51 +0100 Subject: [PATCH 273/365] clean code --- pygsp/graphs/graph.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1b8cf114..860070d0 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -301,12 +301,10 @@ def from_graphtool(cls, graph_gt, weight='weight'): nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) - props_names = graph_gt.edge_properties.keys() if 'vertex_name' in graph_gt.vertex_properties.keys(): vertex_name = graph_gt.vertex_properties['vertex_name'] scalar_vertex = graph_gt.new_vertex_property('int') gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) - # graph_gt.vertex_properties['vertex_name'] = scalar_vertex graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) try: From 9ed165040e624f465e8e865c38869da0adf9e9b8 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 18 Feb 2019 18:32:21 +0100 Subject: [PATCH 274/365] fix verison for test_save_load --- pygsp/tests/test_graphs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 835f5e91..0b880654 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -812,10 +812,10 @@ def test_networkx_signal_import(self): nx.get_node_attributes(g_nx, "signal1")[node]) def test_save_load(self): - g = graphs.Sensor(seed=42) - tested_fmt = ["gml", "gexf", "graphml"] - filename = "graph." - if sys.version_info > (3, 5): + if sys.version_info >= (3, 6): + g = graphs.Sensor(seed=42) + tested_fmt = ["gml", "gexf", "graphml"] + filename = "graph." for fmt in tested_fmt: g.save(filename + fmt) From 86655b123d40264f5563e05c9da5a4f3b156c51c Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 18:59:31 +0100 Subject: [PATCH 275/365] use graph_tool.generation to gen random graph --- pygsp/tests/test_graphs.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 0b880654..af51a0f7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -721,15 +721,9 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - graph_gt = gt.Graph() - graph_gt.add_vertex(100) + graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) - # insert single random links eprop_double = graph_gt.new_edge_property("double") - for s, t in set(zip(np.random.randint(0, 100, 100), - np.random.randint(0, 100, 100))): - graph_gt.add_edge(graph_gt.vertex(s), graph_gt.vertex(t)) - for e in graph_gt.edges(): eprop_double[e] = random.random() graph_gt.edge_properties["weight"] = eprop_double From 77b7adbdcd5c0528def6b7b38340924c0e151f80 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 19:01:47 +0100 Subject: [PATCH 276/365] remove useless isomorphic testing --- pygsp/tests/test_graphs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index af51a0f7..c5c1ed55 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -685,7 +685,6 @@ def test_networkx_import_export(self): g_nx = nx.gnm_random_graph(100, 50) # Generate a random graph g = graphs.Graph.from_networkx(g_nx).to_networkx() - self.assertTrue(nx.is_isomorphic(g_nx, g)) np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) From 879af6d87ffe2797a49920b6094f8dc67022ca95 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 19:21:09 +0100 Subject: [PATCH 277/365] Convert to float the singal when exporting to networkx --- pygsp/graphs/graph.py | 4 ++-- pygsp/tests/test_graphs.py | 22 +++++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 860070d0..9e2128fd 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -191,7 +191,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): - signal_dict = {i: signal[i] for i in range(self.N)} + signal_dict = {i: float(signal[i]) for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx @@ -263,7 +263,7 @@ def from_networkx(cls, graph_nx, weight='weight'): # Add signal previously not present in the dict of signal # Set to zero the value of the signal when not present for a node # in Networkx - for signal in set(signals_name) - set(signals.keys()): + for signal in set(signals_name) - set(signals.keys()): signals[signal] = np.zeros(len(nodelist)) # Set the value of the signal diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c5c1ed55..4efddd03 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -806,30 +806,34 @@ def test_networkx_signal_import(self): def test_save_load(self): if sys.version_info >= (3, 6): - g = graphs.Sensor(seed=42) + graph = graphs.Sensor(seed=42) + np.random.seed(42) + signal = np.random.random(graph.N) + graph.set_signal(signal, "signal") tested_fmt = ["gml", "gexf", "graphml"] filename = "graph." for fmt in tested_fmt: - g.save(filename + fmt) + graph.save(filename + fmt) for fmt in tested_fmt: graph_loaded = graphs.Graph.load(filename + fmt) - np.testing.assert_array_equal(g.W.todense(), graph_loaded.W.todense()) + np.testing.assert_array_equal(graph.W.todense(), graph_loaded.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded.signals['signal']) os.remove(filename + fmt) fmt = "gml" - g.save(filename + fmt, backend='graph_tool') + graph.save(filename + fmt, backend='graph_tool') graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) os.remove(filename + fmt) fmt = 'dot' - g = graphs.Sensor(seed=42) - g.save(filename + fmt, backend='graph_tool') + graph = graphs.Sensor(seed=42) + graph.save(filename + fmt, backend='graph_tool') graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - g = graphs.Sensor(seed=42) - np.testing.assert_allclose(g.W.todense(), graph_loaded.W.todense(), atol=0.000001) + graph = graphs.Sensor(seed=42) + np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) os.remove(filename + fmt) From 4db856d5dd9f7426e11b0d786e59c1ac81cf86ee Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 19 Feb 2019 21:52:41 +0100 Subject: [PATCH 278/365] skip graph tool tests for python2.7 --- pygsp/tests/test_graphs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4efddd03..4c68bfc8 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -691,12 +691,16 @@ def test_networkx_import_export(self): def test_graphtool_export_import(self): # Export to graph tool and reimport to PyGSP directly # The exported graph is a simple one without an associated Signal + if sys.version_info < (3, 0): + return None # skip test for python 2.7 g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) def test_graphtool_multiedge_import(self): + if sys.version_info < (3, 0): + return None # skip test for python2.7 # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) @@ -720,6 +724,8 @@ def test_graphtool_multiedge_import(self): def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal + if sys.version_info < (3, 0): + return None # skip test for python2.7 graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) eprop_double = graph_gt.new_edge_property("double") @@ -766,6 +772,8 @@ def test_graphtool_signal_export(self): self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) def test_graphtool_signal_import(self): + if sys.version_info < (3, 0): + return None # skip test for python2.7 g_gt = gt.Graph() g_gt.add_vertex(10) From ee985acc32841c439066e086655d31c885c4f9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:14:53 +0100 Subject: [PATCH 279/365] Update pygsp/graphs/graph.py Accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9e2128fd..0f3d201f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -178,7 +178,7 @@ def to_networkx(self): The weight are stored as an edge attribute under named `weight` The signals are stored as node attributes under the same name as define in PyGSP - :func:`~pygsp.graphs.Graph.set_signal`. + adding them with :meth:`set_signal`. Returns ------- From caf3cd143c98d184cd28475b89f8984e91376e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:16:40 +0100 Subject: [PATCH 280/365] Update pygsp/graphs/graph.py Accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0f3d201f..6a0f9845 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -177,7 +177,7 @@ def to_networkx(self): r"""Export the graph to an `Networkx `_ object The weight are stored as an edge attribute under named `weight` - The signals are stored as node attributes under the same name as define in PyGSP + The signals are stored as node attributes under the name given when adding them with :meth:`set_signal`. Returns From 062823bee2b5450590b924ef6a6898edc99682a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Feb 2019 22:17:15 +0100 Subject: [PATCH 281/365] Update pygsp/graphs/graph.py accept change Co-Authored-By: cgallay --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6a0f9845..594add5c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -176,7 +176,7 @@ def __repr__(self, limit=None): def to_networkx(self): r"""Export the graph to an `Networkx `_ object - The weight are stored as an edge attribute under named `weight` + The weights are stored as an edge attribute under the name `weight`. The signals are stored as node attributes under the name given when adding them with :meth:`set_signal`. From 5b0a95c307c7d008386190169f5cad89085d8f11 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Wed, 20 Feb 2019 19:18:45 +0100 Subject: [PATCH 282/365] remove support for dot export and import --- pygsp/graphs/graph.py | 34 ++++++++++++--------------- pygsp/tests/test_graphs.py | 48 +++++++++++++++++++------------------- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 594add5c..2da4ef36 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -191,6 +191,7 @@ def to_networkx(self): edge_attribute='weight') for name, signal in self.signals.items(): + # networkx can't work with numpy floats so we convert the singal into python float signal_dict = {i: float(signal[i]) for i in range(self.N)} nx.set_node_attributes(graph_nx, signal_dict, name) return graph_nx @@ -301,17 +302,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): nb_edges = graph_gt.num_edges() weights = np.zeros(shape=(nb_vertex, nb_vertex)) - if 'vertex_name' in graph_gt.vertex_properties.keys(): - vertex_name = graph_gt.vertex_properties['vertex_name'] - scalar_vertex = graph_gt.new_vertex_property('int') - gt.map_property_values(vertex_name, scalar_vertex, lambda x: int(x)) - graph_gt = gt.Graph(graph_gt, vorder=scalar_vertex) - try: prop = graph_gt.edge_properties[weight] - scalar_prop = graph_gt.new_edge_property('double') - gt.map_property_values(prop, scalar_prop, lambda x: float(x)) - edge_weight = scalar_prop.get_array() + edge_weight = prop.get_array() if edge_weight is None: warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") edge_weight = np.ones(nb_edges) @@ -348,7 +341,7 @@ def load(cls, path, fmt='auto', backend='auto'): ---------- path : String Where the file is located on the disk. - fmt : {'graphml', 'gml', 'gexf', 'dot', 'auto'} + fmt : {'graphml', 'gml', 'gexf', 'auto'} Format in which the graph is encoded. backend : String Python library used in background to load the graph. @@ -378,11 +371,13 @@ def load_graph_tool(saved_path, format): else: backend = 'graph_tool' - if fmt not in ['graphml', 'gml', 'gexf', 'dot']: - raise ValueError('Unsupported format {}.'.format(fmt)) + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {}.'.format(backend)) + raise ValueError( + 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) return locals()['load_' + backend](path, fmt) @@ -397,7 +392,7 @@ def save(self, path, fmt='auto', backend='auto'): Format in which the graph will be encoded. The format is guessed from the `path` extention when fmt is set to 'auto' Currently supported format are: - GML and gpickle. + ['graphml', 'gml', 'gexf'] backend : String Python library used in background to save the graph. Supported library are networkx and graph_tool @@ -410,8 +405,8 @@ def save_networkx(graph, save_path): save(graph_nx, save_path) def save_graph_tool(graph, save_path): - graph_gt = self.to_graphtool() - graph_gt.save(path, fmt=fmt) + graph_gt = graph.to_graphtool() + graph_gt.save(save_path, fmt=fmt) if fmt == 'auto': fmt = path.split('.')[-1] @@ -422,11 +417,12 @@ def save_graph_tool(graph, save_path): else: backend = 'graph_tool' - if fmt not in ['graphml', 'gml', 'gexf', 'dot']: - raise ValueError('Unsupported format {}.'.format(fmt)) + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {}.'.format(backend)) + raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) locals()['save_' + backend](self, path) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4c68bfc8..906b5e57 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -818,31 +818,31 @@ def test_save_load(self): np.random.seed(42) signal = np.random.random(graph.N) graph.set_signal(signal, "signal") - tested_fmt = ["gml", "gexf", "graphml"] - filename = "graph." - for fmt in tested_fmt: - graph.save(filename + fmt) - for fmt in tested_fmt: - graph_loaded = graphs.Graph.load(filename + fmt) - np.testing.assert_array_equal(graph.W.todense(), graph_loaded.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded.signals['signal']) - os.remove(filename + fmt) - - fmt = "gml" - - graph.save(filename + fmt, backend='graph_tool') - graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) - os.remove(filename + fmt) - - fmt = 'dot' - graph = graphs.Sensor(seed=42) - graph.save(filename + fmt, backend='graph_tool') - graph_loaded = graphs.Graph.load(filename + fmt, backend='graph_tool') - graph = graphs.Sensor(seed=42) - np.testing.assert_allclose(graph.W.todense(), graph_loaded.W.todense(), atol=0.000001) - os.remove(filename + fmt) + # save + nx_gt = ['gml', 'graphml'] + all_files = [] + for fmt in nx_gt: + all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] + graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_nx.{}".format(fmt), backend='networkx') + graph.save("graph_nx.{}".format('gexf'), backend='networkx') + all_files += ["graph_nx.{}".format('gexf')] + + # load + for filename in all_files: + if not "_gt" in filename: + graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') + np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) + if not ".gexf" in filename: + graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) + np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) + + # clean + for filename in all_files: + os.remove(filename) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From 40361c6fb38a5ee52f1c057b0f31a1f9e8b1fcce Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Thu, 21 Feb 2019 11:47:43 +0100 Subject: [PATCH 283/365] use graph_tool.spectral.adjacency --- pygsp/graphs/graph.py | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 2da4ef36..0974dafa 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -298,33 +298,10 @@ def from_graphtool(cls, graph_gt, weight='weight'): The weight of the graph are loaded from the edge property named ``edge_prop_name`` """ import graph_tool as gt - nb_vertex = graph_gt.num_vertices() - nb_edges = graph_gt.num_edges() - weights = np.zeros(shape=(nb_vertex, nb_vertex)) - - try: - prop = graph_gt.edge_properties[weight] - edge_weight = prop.get_array() - if edge_weight is None: - warnings.warn("edge_prop_name refered to a non scalar property, a weight of 1.0 is given to each edge") - edge_weight = np.ones(nb_edges) - - except KeyError: - warnings.warn("""As the property {} is not found in the graph, a weight of 1.0 is given to each edge""" - .format(weight)) - edge_weight = np.ones(nb_edges) - - # merging multi-edge - merged_edge_weight = [] - for k, grp in groupby(graph_gt.get_edges(), key=lambda e: (e[0], e[1])): - merged_edge_weight.append((k[0], k[1], sum([edge_weight[e[2]] for e in grp]))) - for e in merged_edge_weight: - weights[e[0], e[1]] = e[2] - # When the graph is not directed the opposit edge as to be added too. - if not graph_gt.is_directed(): - for e in merged_edge_weight: - weights[e[1], e[0]] = e[2] - graph = cls(weights) + import graph_tool.spectral + + weight_property = graph_gt.edge_properties.get(weight, None) + graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) # Adding signals for signal_name, signal_gt in graph_gt.vertex_properties.items(): @@ -370,7 +347,7 @@ def load_graph_tool(saved_path, format): backend = 'networkx' else: backend = 'graph_tool' - + supported_format = ['graphml', 'gml', 'gexf'] if fmt not in supported_format: raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) From df5bb095b109a272f33eeb3682de5cd6754edfb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:01:41 +0100 Subject: [PATCH 284/365] RandomRing: use a kernel --- pygsp/graphs/randomring.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 1c24a1d5..3dad7436 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -62,15 +62,12 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): W = sparse.coo_matrix((weights, (rows, cols)), shape=(N, N)) W = utils.symmetrize(W, method='triu') - # Width as the expected angle. All angles are equal to that value when - # the ring is uniformly sampled. - width = 2 * np.pi / N - assert (W.data.mean() - width) < 1e-10 # TODO: why this kernel ? It empirically produces eigenvectors closer # to the sines and cosines. - W.data = width / W.data + W.data = 1 / W.data - coords = np.stack([np.cos(angles), np.sin(angles)], axis=1) + angle = position * 2 * np.pi + coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} # TODO: save angle and 2D position as graph signals From 9ceba3a7ed31635bf4a8832376a767c4f9937bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:03:53 +0100 Subject: [PATCH 285/365] RandomRing: cleanup --- pygsp/graphs/randomring.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 3dad7436..303e79fc 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -35,21 +35,10 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): self.seed = seed - if angles is None: - rs = np.random.RandomState(seed) - angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) - else: - angles = np.asanyarray(angles) - angles.sort() # Need to be sorted to take the difference. - N = len(angles) - if np.any(angles < 0) or np.any(angles >= 2*np.pi): - raise ValueError('Angles should be in [0, 2 pi]') + rs = np.random.RandomState(seed) + angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) self.angles = angles - if N < 3: - # Asymmetric graph needed for 2 as 2 distances connect them. - raise ValueError('There should be at least 3 vertices.') - rows = range(0, N-1) cols = range(1, N) weights = np.diff(angles) @@ -62,16 +51,19 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): W = sparse.coo_matrix((weights, (rows, cols)), shape=(N, N)) W = utils.symmetrize(W, method='triu') + # Width as the expected angle. All angles are equal to that value when + # the ring is uniformly sampled. + width = 2 * np.pi / N + assert (W.data.mean() - width) < 1e-10 # TODO: why this kernel ? It empirically produces eigenvectors closer # to the sines and cosines. - W.data = 1 / W.data + W.data = width / W.data - angle = position * 2 * np.pi - coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) + coords = np.stack([np.cos(angles), np.sin(angles)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} # TODO: save angle and 2D position as graph signals - super(RandomRing, self).__init__(W, coords=coords, plotting=plotting, + super(RandomRing, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): From 00753c12f5bfa8f3dc5f547b62116ebd6922163f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:32:41 +0100 Subject: [PATCH 286/365] StochasticBlockModel: allow to indefinitely try to get a connected graph --- pygsp/graphs/stochasticblockmodel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 63918b72..6c9ec6ac 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -133,7 +133,8 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break - if Graph(W).is_connected(): + self.W = W + if self.is_connected(recompute=True): break if n_try is not None: n_try -= 1 From da1876df548e3a4a202f581827634490d4490cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Feb 2019 12:47:57 +0100 Subject: [PATCH 287/365] merge all the extra requirements in a single dev requirement --- CONTRIBUTING.rst | 3 +++ doc/history.rst | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f18d202d..ddfd8afb 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -15,11 +15,14 @@ development with the following:: $ git clone https://github.com/epfl-lts2/pygsp.git $ pip install -U -e pygsp[dev] +<<<<<<< HEAD The ``dev`` "extras requirement" ensures that dependencies required for development (to run the test suite and build the documentation) are installed. Only `graph-tool `_ will be missing: install it manually as it cannot be installed by pip. +======= +>>>>>>> merge all the extra requirements in a single dev requirement You can improve or add functionality in the ``pygsp`` folder, along with corresponding unit tests in ``pygsp/tests/test_*.py`` (with reasonable diff --git a/doc/history.rst b/doc/history.rst index a3cc366f..f0522de7 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -24,9 +24,13 @@ History * New implementation of the Sensor graph that is simpler and scales better. * A new learning module with three functions to solve standard semi-supervised classification and regression problems. +<<<<<<< HEAD * A much improved, fixed, documented, and tested NNGraph. The user can now select the backend and similarity kernel. The radius can be estimated and features standardized. (PR #43) +======= +* Merged all the extra requirements in a single dev requirement. +>>>>>>> merge all the extra requirements in a single dev requirement Experimental filter API (to be tested and validated): From b2e0ff7d6acf68efac666065d6903b817b5e5b1a Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 13:49:12 +0100 Subject: [PATCH 288/365] Add examples --- pygsp/graphs/graph.py | 46 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0974dafa..f913265f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -183,6 +183,13 @@ def to_networkx(self): Returns ------- graph_nx : :py:class:`networkx.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> nx_graph = graph.to_networkx() + >>> nx_graph.number_of_nodes() + 1130 """ import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( @@ -205,6 +212,12 @@ def to_graphtool(self): Returns ------- graph_gt : :py:class:`graph_tool.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> gt_graph = graph.to_graphtool() + >>> weight_property = gt_graph.edge_properties["weight"] """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) @@ -250,6 +263,12 @@ def from_networkx(cls, graph_nx, weight='weight'): Returns ------- graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> import networkx as nx + >>> nx_graph = nx.random_geometric_graph(200, 0.125) + >>> graph = graphs.Graph.from_networkx(nx_graph) """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array @@ -296,10 +315,16 @@ def from_graphtool(cls, graph_gt, weight='weight'): ------- graph : :class:`~pygsp.graphs.Graph` The weight of the graph are loaded from the edge property named ``edge_prop_name`` + + Examples + -------- + >>> from graph_tool.all import Graph + >>> gt_graph = Graph() + >>> graph = graphs.Graph.from_graphtool(gt_graph) """ import graph_tool as gt import graph_tool.spectral - + weight_property = graph_gt.edge_properties.get(weight, None) graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) @@ -327,6 +352,10 @@ def load(cls, path, fmt='auto', backend='auto'): Returns ------- graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> graph = graphs.Graph.load('logo.graphml') """ def load_networkx(saved_path, format): @@ -347,7 +376,7 @@ def load_graph_tool(saved_path, format): backend = 'networkx' else: backend = 'graph_tool' - + supported_format = ['graphml', 'gml', 'gexf'] if fmt not in supported_format: raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) @@ -374,6 +403,11 @@ def save(self, path, fmt='auto', backend='auto'): Python library used in background to save the graph. Supported library are networkx and graph_tool WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') """ def save_networkx(graph, save_path): import networkx as nx @@ -413,6 +447,14 @@ def set_signal(self, signal, name): An array mapping from node to his value. For example the value of the signal at node i is signal[i] name : String Name associated to the signal. + + Examples + -------- + >>> graph = graphs.Logo() + >>> DELTAS = [20, 30, 1090] + >>> signal = np.zeros(graph.N) + >>> signal[DELTAS] = 1 + >>> graph.set_signal(signal, 'diffusion') """ if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") From 717c5f004ea5c46f05f2e2894a79ca3c3afdaa9e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 14:59:22 +0100 Subject: [PATCH 289/365] fix doctest --- pygsp/graphs/graph.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index f913265f..b62a8dee 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -188,8 +188,9 @@ def to_networkx(self): -------- >>> graph = graphs.Logo() >>> nx_graph = graph.to_networkx() - >>> nx_graph.number_of_nodes() + >>> print(nx_graph.number_of_nodes()) 1130 + """ import networkx as nx graph_nx = nx.from_scipy_sparse_matrix( @@ -218,6 +219,7 @@ def to_graphtool(self): >>> graph = graphs.Logo() >>> gt_graph = graph.to_graphtool() >>> weight_property = gt_graph.edge_properties["weight"] + """ import graph_tool graph_gt = graph_tool.Graph(directed=self.is_directed()) @@ -267,8 +269,9 @@ def from_networkx(cls, graph_nx, weight='weight'): Examples -------- >>> import networkx as nx - >>> nx_graph = nx.random_geometric_graph(200, 0.125) + >>> nx_graph = nx.star_graph(200) >>> graph = graphs.Graph.from_networkx(nx_graph) + """ import networkx as nx # keep a consistent order of nodes for the agency matrix and the signal array @@ -320,7 +323,9 @@ def from_graphtool(cls, graph_gt, weight='weight'): -------- >>> from graph_tool.all import Graph >>> gt_graph = Graph() + >>> gt_graph.add_vertex(10) >>> graph = graphs.Graph.from_graphtool(gt_graph) + """ import graph_tool as gt import graph_tool.spectral @@ -355,7 +360,9 @@ def load(cls, path, fmt='auto', backend='auto'): Examples -------- + >>> graphs.Logo().save('logo.graphml') >>> graph = graphs.Graph.load('logo.graphml') + """ def load_networkx(saved_path, format): @@ -408,6 +415,7 @@ def save(self, path, fmt='auto', backend='auto'): -------- >>> graph = graphs.Logo() >>> graph.save('logo.graphml') + """ def save_networkx(graph, save_path): import networkx as nx @@ -455,6 +463,7 @@ def set_signal(self, signal, name): >>> signal = np.zeros(graph.N) >>> signal[DELTAS] = 1 >>> graph.set_signal(signal, 'diffusion') + """ if len(signal) != self.N: raise ValueError("A value must be attached to every vertex in the graph") From 672edac93fd1b2dd731ae0913bb49f540b9efb72 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Tue, 26 Feb 2019 16:37:26 +0100 Subject: [PATCH 290/365] fix expcted nothing error --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b62a8dee..90c1ca71 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -323,7 +323,7 @@ def from_graphtool(cls, graph_gt, weight='weight'): -------- >>> from graph_tool.all import Graph >>> gt_graph = Graph() - >>> gt_graph.add_vertex(10) + >>> _ = gt_graph.add_vertex(10) >>> graph = graphs.Graph.from_graphtool(gt_graph) """ From 33704fd30724b8a24bb0c2859fb6cdadb68fa000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 16:26:56 +0100 Subject: [PATCH 291/365] rings need at least 3 vertices --- pygsp/graphs/randomring.py | 4 ++++ pygsp/tests/test_graphs.py | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 303e79fc..466557fc 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -39,6 +39,10 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) self.angles = angles + if N < 3: + # Asymmetric graph needed for 2 as 2 distances connect them. + raise ValueError('There should be at least 3 vertices.') + rows = range(0, N-1) cols = range(1, N) weights = np.diff(angles) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 906b5e57..871c6c9b 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -640,12 +640,8 @@ def test_path(self): def test_randomring(self): graphs.RandomRing() - G = graphs.RandomRing(angles=[0, 2, 1]) - self.assertEqual(G.N, 3) self.assertRaises(ValueError, graphs.RandomRing, 2) self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2]) - self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, 7]) - self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, -1]) def test_swissroll(self): graphs.SwissRoll(srtype='uniform') From 7e076987767865fa6aaa7d66e3920af1cbf97538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 27 Feb 2019 16:27:29 +0100 Subject: [PATCH 292/365] RandomRing: allow user to pass angles --- pygsp/graphs/randomring.py | 13 ++++++++++--- pygsp/tests/test_graphs.py | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 466557fc..5527665f 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -14,7 +14,7 @@ class RandomRing(Graph): ---------- N : int Number of vertices. - angles : array_like, optional + angles : array-like, optional The angular coordinate, in :math:`[0, 2\pi]`, of the vertices. seed : int Seed for the random number generator (for reproducible graphs). @@ -35,8 +35,15 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): self.seed = seed - rs = np.random.RandomState(seed) - angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) + if angles is None: + rs = np.random.RandomState(seed) + angles = np.sort(rs.uniform(0, 2*np.pi, size=N), axis=0) + else: + angles = np.asanyarray(angles) + angles.sort() # Need to be sorted to take the difference. + N = len(angles) + if np.any(angles < 0) or np.any(angles >= 2*np.pi): + raise ValueError('Angles should be in [0, 2 pi]') self.angles = angles if N < 3: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 871c6c9b..906b5e57 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -640,8 +640,12 @@ def test_path(self): def test_randomring(self): graphs.RandomRing() + G = graphs.RandomRing(angles=[0, 2, 1]) + self.assertEqual(G.N, 3) self.assertRaises(ValueError, graphs.RandomRing, 2) self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2]) + self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, 7]) + self.assertRaises(ValueError, graphs.RandomRing, angles=[0, 2, -1]) def test_swissroll(self): graphs.SwissRoll(srtype='uniform') From 06656f6ef90dbfe374d530aea8c4fb1a41c1dbf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Feb 2019 23:13:25 +0100 Subject: [PATCH 293/365] improve is_connected --- pygsp/graphs/graph.py | 2 +- pygsp/graphs/stochasticblockmodel.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 90c1ca71..87eff726 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -613,7 +613,7 @@ def is_connected(self): return self._connected adjacencies = [self.W] - if self.is_directed(): + if self.is_directed(recompute=recompute): adjacencies.append(self.W.T) for adjacency in adjacencies: diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 6c9ec6ac..409534cb 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -134,6 +134,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break self.W = W + self.n_vertices = W.shape[0] if self.is_connected(recompute=True): break if n_try is not None: From 6ee4e2cf761547fce6c43145bc178b84c63cc927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 00:58:56 +0100 Subject: [PATCH 294/365] test connectedness --- pygsp/tests/test_graphs.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 906b5e57..687542f8 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -148,6 +148,36 @@ def test_is_directed(self): # assert graph.W.nnz == 6 # self.assertEqual(graph.is_directed(recompute=True), False) + def test_is_connected(self): + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 2], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), False) + self.assertEqual(graph.is_connected(), True) + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 0], + [0, 2, 0], + ]) + self.assertEqual(graph.is_directed(), True) + self.assertEqual(graph.is_connected(), False) + graph = graphs.Graph([ + [0, 1, 0], + [1, 0, 0], + [0, 0, 0], + ]) + self.assertEqual(graph.is_directed(), False) + self.assertEqual(graph.is_connected(), False) + graph = graphs.Graph([ + [0, 1, 0], + [0, 0, 2], + [3, 0, 0], + ]) + self.assertEqual(graph.is_directed(), True) + self.assertEqual(graph.is_connected(), True) + def test_laplacian(self): G = graphs.Graph([ From 5db04e4d4f1cb28fd0d2cb27042828c61e118b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:04:32 +0100 Subject: [PATCH 295/365] accept adjacencies as list of lists --- pygsp/graphs/difference.py | 10 +++--- pygsp/graphs/graph.py | 69 +++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 7bd54ef7..49b9457e 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -95,11 +95,12 @@ def compute_differential_operator(self): The difference operator is an incidence matrix. Example with a undirected graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() @@ -115,11 +116,12 @@ def compute_differential_operator(self): Example with a directed graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 87eff726..969834d4 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -102,15 +102,19 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.logger = utils.build_logger(__name__) - if not sparse.isspmatrix(adjacency): - adjacency = np.asanyarray(adjacency) - - if (adjacency.ndim != 2) or (adjacency.shape[0] != adjacency.shape[1]): - raise ValueError('Adjacency: must be a square matrix.') - # CSR sparse matrices are the most efficient for matrix multiplication. # They are the sole sparse matrix type to support eliminate_zeros(). - self._adjacency = sparse.csr_matrix(adjacency, copy=False) + if sparse.isspmatrix_csr(W): + self.W = W + elif sparse.isspmatrix(W): + self.W = W.tocsr() + else: + self.W = sparse.csr_matrix(np.asanyarray(W)) + + if len(self.W.shape) != 2 or self.W.shape[0] != self.W.shape[1]: + raise ValueError('W has incorrect shape {}'.format(self.W.shape)) + + self.n_vertices = self.W.shape[0] if np.isnan(self._adjacency.sum()): raise ValueError('Adjacency: there is a Not a Number (NaN).') @@ -123,14 +127,9 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, if (self._adjacency < 0).nnz != 0: self.logger.warning('Adjacency: there are negative edge weights.') - self.n_vertices = self._adjacency.shape[0] - - # Don't keep edges of 0 weight. Otherwise n_edges will not correspond - # to the real number of edges. Problematic when plotting. - self._adjacency.eliminate_zeros() - - self._directed = None - self._connected = None + # TODO: why would we ever want this? + # For large matrices it slows the graph construction by a factor 100. + # self.W = sparse.lil_matrix(self.W) # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. @@ -587,23 +586,25 @@ def is_connected(self): Connected graph: - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.is_connected() True Disconnected graph: - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 0, 0, 2], ... [0, 0, 2, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.is_connected() False @@ -653,21 +654,23 @@ def is_directed(self): Directed graph: - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.is_directed() True Undirected graph: - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.is_directed() False @@ -811,11 +814,12 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of an undirected graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2., -2., 0.], @@ -829,11 +833,12 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of a directed graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2. , -2. , 0. ], @@ -1253,22 +1258,24 @@ def get_edge_list(self): Edge list of a directed graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1, 1], [1, 0, 2], [3, 3, 4]) Edge list of an undirected graph. - >>> graph = graphs.Graph([ + >>> adjacency = [ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ]) + ... ] + >>> graph = graphs.Graph(adjacency) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1], [1, 2], [3, 4]) From 7da31931749ac8ce2cb6bd072c74120cbd481df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:10:00 +0100 Subject: [PATCH 296/365] accept signals as array_like --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 969834d4..0a0667b6 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -906,7 +906,7 @@ def compute_laplacian(self, lap_type='combinatorial'): def _check_signal(self, s): r"""Check if signal is valid.""" s = np.asanyarray(s) - if s.shape[0] != self.n_vertices: + if s.shape[0] != self.N: raise ValueError('First dimension must be the number of vertices ' 'G.N = {}, got {}.'.format(self.N, s.shape)) return s From a0c188ae674bb07b0f870d40adf8e843f3105955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:31:08 +0100 Subject: [PATCH 297/365] array-like => array_like --- pygsp/graphs/graph.py | 99 +++++++++++++++++++++++++++++++++++++- pygsp/graphs/randomring.py | 2 +- 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0a0667b6..bb653c3d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -523,8 +523,103 @@ def check_weights(self): 'is_not_square': is_not_square, 'diag_is_not_zero': diag_is_not_zero} - def subgraph(self, vertices): - r"""Create a subgraph from a list of vertices. + def set_coordinates(self, kind='spring', **kwargs): + r"""Set node's coordinates (their position when plotting). + + Parameters + ---------- + kind : string or array_like + Kind of coordinates to generate. It controls the position of the + nodes when plotting the graph. Can either pass an array of size Nx2 + or Nx3 to set the coordinates manually or the name of a layout + algorithm. Available algorithms: community2D, random2D, random3D, + ring2D, line1D, spring, laplacian_eigenmap2D, laplacian_eigenmap3D. + Default is 'spring'. + kwargs : dict + Additional parameters to be passed to the Fruchterman-Reingold + force-directed algorithm when kind is spring. + + Examples + -------- + >>> G = graphs.ErdosRenyi() + >>> G.set_coordinates() + >>> fig, ax = G.plot() + + """ + + if not isinstance(kind, str): + coords = np.asarray(kind).squeeze() + check_1d = (coords.ndim == 1) + check_2d_3d = (coords.ndim == 2) and (2 <= coords.shape[1] <= 3) + if coords.shape[0] != self.N or not (check_1d or check_2d_3d): + raise ValueError('Expecting coordinates to be of size N, Nx2, ' + 'or Nx3.') + self.coords = coords + + elif kind == 'line1D': + self.coords = np.arange(self.N) + + elif kind == 'line2D': + x, y = np.arange(self.N), np.zeros(self.N) + self.coords = np.stack([x, y], axis=1) + + elif kind == 'ring2D': + angle = np.arange(self.N) * 2 * np.pi / self.N + self.coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) + + elif kind == 'random2D': + self.coords = np.random.uniform(size=(self.N, 2)) + + elif kind == 'random3D': + self.coords = np.random.uniform(size=(self.N, 3)) + + elif kind == 'spring': + self.coords = self._fruchterman_reingold_layout(**kwargs) + + elif kind == 'community2D': + if not hasattr(self, 'info') or 'node_com' not in self.info: + ValueError('Missing arguments to the graph to be able to ' + 'compute community coordinates.') + + if 'world_rad' not in self.info: + self.info['world_rad'] = np.sqrt(self.N) + + if 'comm_sizes' not in self.info: + counts = Counter(self.info['node_com']) + self.info['comm_sizes'] = np.array([cnt[1] for cnt + in sorted(counts.items())]) + + Nc = self.info['comm_sizes'].shape[0] + + self.info['com_coords'] = self.info['world_rad'] * \ + np.array(list(zip( + np.cos(2 * np.pi * np.arange(1, Nc + 1) / Nc), + np.sin(2 * np.pi * np.arange(1, Nc + 1) / Nc)))) + + # Coordinates of the nodes inside their communities + coords = np.random.rand(self.N, 2) + self.coords = np.array([[elem[0] * np.cos(2 * np.pi * elem[1]), + elem[0] * np.sin(2 * np.pi * elem[1])] + for elem in coords]) + + for i in range(self.N): + # Set coordinates as an offset from the center of the community + # it belongs to + comm_idx = self.info['node_com'][i] + comm_rad = np.sqrt(self.info['comm_sizes'][comm_idx]) + self.coords[i] = self.info['com_coords'][comm_idx] + \ + comm_rad * self.coords[i] + elif kind == 'laplacian_eigenmap2D': + self.compute_fourier_basis(n_eigenvectors=2) + self.coords = self.U[:, 1:3] + elif kind == 'laplacian_eigenmap3D': + self.compute_fourier_basis(n_eigenvectors=3) + self.coords = self.U[:, 1:4] + else: + raise ValueError('Unexpected argument kind={}.'.format(kind)) + + def subgraph(self, ind): + r"""Create a subgraph given indices. Parameters ---------- diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 5527665f..a28a11c0 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -14,7 +14,7 @@ class RandomRing(Graph): ---------- N : int Number of vertices. - angles : array-like, optional + angles : array_like, optional The angular coordinate, in :math:`[0, 2\pi]`, of the vertices. seed : int Seed for the random number generator (for reproducible graphs). From e0561ab6b8cd4bbbc59b987fed82fa41adae5d5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:36:17 +0100 Subject: [PATCH 298/365] prefer asanyarray over asarray --- pygsp/graphs/graph.py | 104 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index bb653c3d..054a33c2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -548,7 +548,7 @@ def set_coordinates(self, kind='spring', **kwargs): """ if not isinstance(kind, str): - coords = np.asarray(kind).squeeze() + coords = np.asanyarray(kind).squeeze() check_1d = (coords.ndim == 1) check_2d_3d = (coords.ndim == 2) and (2 <= coords.shape[1] <= 3) if coords.shape[0] != self.N or not (check_1d or check_2d_3d): @@ -1409,3 +1409,105 @@ def plot_spectrogram(self, node_idx=None): r"""Docstring overloaded at import time.""" from pygsp.plotting import _plot_spectrogram _plot_spectrogram(self, node_idx=node_idx) + + def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], + iterations=50, scale=1.0, center=None, + seed=None): + # TODO doc + # fixed: list of nodes with fixed coordinates + # Position nodes using Fruchterman-Reingold force-directed algorithm. + + if center is None: + center = np.zeros((1, dim)) + + if np.shape(center)[1] != dim: + self.logger.error('Spring coordinates: center has wrong size.') + center = np.zeros((1, dim)) + + if pos is None: + dom_size = 1 + pos_arr = None + else: + # Determine size of existing domain to adjust initial positions + dom_size = np.max(pos) + pos_arr = np.random.RandomState(seed).uniform(size=(self.N, dim)) + pos_arr = pos_arr * dom_size + center + for i in range(self.N): + pos_arr[i] = np.asanyarray(pos[i]) + + if k is None and len(fixed) > 0: + # We must adjust k by domain size for layouts that are not near 1x1 + k = dom_size / np.sqrt(self.N) + + pos = _sparse_fruchterman_reingold(self.A, dim, k, pos_arr, + fixed, iterations, seed) + + if len(fixed) == 0: + pos = _rescale_layout(pos, scale=scale) + center + + return pos + + +def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): + # Position nodes in adjacency matrix A using Fruchterman-Reingold + nnodes = A.shape[0] + + # make sure we have a LIst of Lists representation + try: + A = A.tolil() + except Exception: + A = (sparse.coo_matrix(A)).tolil() + + if pos is None: + # random initial positions + pos = np.random.RandomState(seed).uniform(size=(nnodes, dim)) + + # optimal distance between nodes + if k is None: + k = np.sqrt(1.0 / nnodes) + + # simple cooling scheme. + # linearly step down by dt on each iteration so last iteration is size dt. + t = 0.1 + dt = t / float(iterations + 1) + + displacement = np.zeros((dim, nnodes)) + for iteration in range(iterations): + displacement *= 0 + # loop over rows + for i in range(nnodes): + if i in fixed: + continue + # difference between this row's node position and all others + delta = (pos[i] - pos).T + # distance between points + distance = np.sqrt((delta**2).sum(axis=0)) + # enforce minimum distance of 0.01 + distance = np.where(distance < 0.01, 0.01, distance) + # the adjacency matrix row + Ai = A[i, :].toarray() + # displacement "force" + displacement[:, i] += \ + (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) + # update positions + length = np.sqrt((displacement**2).sum(axis=0)) + length = np.where(length < 0.01, 0.1, length) + pos += (displacement * t / length).T + # cool temperature + t -= dt + + return pos + + +def _rescale_layout(pos, scale=1): + # rescale to (-scale, scale) in all axes + + # shift origin to (0,0) + lim = 0 # max coordinate for all axes + for i in range(pos.shape[1]): + pos[:, i] -= pos[:, i].mean() + lim = max(pos[:, i].max(), lim) + # rescale to (-scale,scale) in all directions, preserves aspect + for i in range(pos.shape[1]): + pos[:, i] *= scale / lim + return pos From be1b4abfbc00d8a3549845110451eb188cc3336a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:39:55 +0100 Subject: [PATCH 299/365] improve and test subgraph --- pygsp/graphs/graph.py | 31 ++++++++++++++----------------- pygsp/tests/test_graphs.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 054a33c2..7c72cc7f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -618,14 +618,13 @@ def set_coordinates(self, kind='spring', **kwargs): else: raise ValueError('Unexpected argument kind={}.'.format(kind)) - def subgraph(self, ind): - r"""Create a subgraph given indices. + def subgraph(self, vertices): + r"""Create a subgraph from a list of vertices. Parameters ---------- vertices : list - Vertices to keep. - Either a list of indices or an indicator function. + List of vertices to keep. Returns ------- @@ -634,17 +633,18 @@ def subgraph(self, ind): Examples -------- - >>> graph = graphs.Graph([ - ... [0., 3., 0., 0.], - ... [3., 0., 4., 0.], - ... [0., 4., 0., 2.], - ... [0., 0., 2., 0.], - ... ]) + >>> adjacency = [ + ... [0, 3, 0, 0], + ... [3, 0, 4, 0], + ... [0, 4, 0, 2], + ... [0, 0, 2, 0], + ... ] + >>> graph = graphs.Graph(adjacency) >>> graph = graph.subgraph([0, 2, 1]) >>> graph.W.toarray() - array([[0., 0., 3.], - [0., 0., 4.], - [3., 4., 0.]]) + array([[0, 0, 3], + [0, 0, 4], + [3, 4, 0]], dtype=int64) """ adjacency = self.W[vertices, :][:, vertices] @@ -652,10 +652,7 @@ def subgraph(self, ind): coords = self.coords[vertices] except AttributeError: coords = None - graph = Graph(adjacency, self.lap_type, coords, self.plotting) - for name, signal in self.signals.items(): - graph.set_signal(signal[vertices], name) - return graph + return Graph(adjacency, self.lap_type, coords, self.plotting) def is_connected(self): r"""Check if the graph is connected (cached). diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 687542f8..6513c948 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -476,6 +476,7 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') +<<<<<<< HEAD def test_nngraph(self, n_vertices=24): """Test all the combinations of metric, kind, backend.""" Graph = graphs.NNGraph @@ -580,6 +581,32 @@ def test_nngraph(self, n_vertices=24): # Attributes. self.assertEqual(Graph(data, kind='knn').radius, None) self.assertEqual(Graph(data, kind='radius').k, None) +======= + def test_subgraph(self, n_vertices=100): + graph = self._G.subgraph(range(n_vertices)) + self.assertEqual(graph.n_vertices, n_vertices) + self.assertEqual(graph.coords.shape, (n_vertices, 2)) + self.assertIs(graph.lap_type, self._G.lap_type) + self.assertEqual(graph.plotting, self._G.plotting) + + def test_nngraph(self, n_vertices=30): + rs = np.random.RandomState(42) + Xin = rs.normal(size=(n_vertices, 3)) + dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + + for dist_type in dist_types: + + # Only p-norms with 1<=p<=infinity permitted. + if dist_type != 'minkowski': + graphs.NNGraph(Xin, NNtype='radius', dist_type=dist_type) + graphs.NNGraph(Xin, NNtype='knn', dist_type=dist_type) + + # Distance type unsupported in the C bindings, + # use the C++ bindings instead. + if dist_type != 'max_dist': + graphs.NNGraph(Xin, use_flann=True, NNtype='knn', + dist_type=dist_type) +>>>>>>> improve and test subgraph def test_bunny(self): graphs.Bunny() From c804cc46b8d35558cf4608a32e13714d0beffa0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 01:43:53 +0100 Subject: [PATCH 300/365] improve and test is_directed --- pygsp/graphs/graph.py | 13 ++++++++++--- pygsp/tests/test_graphs.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7c72cc7f..bfe2928f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -736,10 +736,15 @@ def is_directed(self): In this framework, we consider that a graph is directed if and only if its weight matrix is not symmetric. + Parameters + ---------- + recompute : bool + Force to recompute the directedness if already known. + Returns ------- directed : bool - True if the graph is directed, False otherwise. + True if the graph is directed. Examples -------- @@ -767,8 +772,10 @@ def is_directed(self): False """ - if self._directed is None: - self._directed = (self.W != self.W.T).nnz != 0 + if hasattr(self, '_directed') and not recompute: + return self._directed + + self._directed = (self.W != self.W.T).nnz != 0 return self._directed def has_loops(self): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6513c948..456131bc 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -178,6 +178,22 @@ def test_is_connected(self): self.assertEqual(graph.is_directed(), True) self.assertEqual(graph.is_connected(), True) + def test_is_directed(self): + graph = graphs.Graph([ + [0, 3, 0, 0], + [3, 0, 4, 0], + [0, 4, 0, 2], + [0, 0, 2, 0], + ]) + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(), False) + graph.W[0, 1] = 0 + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(recompute=True), True) + graph.W[1, 0] = 0 + assert graph.W.nnz == 6 + self.assertEqual(graph.is_directed(recompute=True), False) + def test_laplacian(self): G = graphs.Graph([ From f991174ebd27a16e9dc3cbbab20cfcc4c75c3b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 02:08:01 +0100 Subject: [PATCH 301/365] simplify --- pygsp/graphs/difference.py | 10 ++++----- pygsp/graphs/graph.py | 45 +++++++++++++++----------------------- 2 files changed, 22 insertions(+), 33 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 49b9457e..7bd54ef7 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -95,12 +95,11 @@ def compute_differential_operator(self): The difference operator is an incidence matrix. Example with a undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() @@ -116,12 +115,11 @@ def compute_differential_operator(self): Example with a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.compute_differential_operator() >>> graph.D.toarray() diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index bfe2928f..ed52b0b7 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -633,13 +633,12 @@ def subgraph(self, vertices): Examples -------- - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph = graph.subgraph([0, 2, 1]) >>> graph.W.toarray() array([[0, 0, 3], @@ -678,25 +677,23 @@ def is_connected(self): Connected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 4, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_connected() True Disconnected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0, 0], ... [3, 0, 4, 0], ... [0, 0, 0, 2], ... [0, 0, 2, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_connected() False @@ -751,23 +748,21 @@ def is_directed(self): Directed graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_directed() True Undirected graph: - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.is_directed() False @@ -913,12 +908,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of an undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 1, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2., -2., 0.], @@ -932,12 +926,11 @@ def compute_laplacian(self, lap_type='combinatorial'): Combinatorial and normalized Laplacians of a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 2, 0], ... [2, 0, 1], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> graph.compute_laplacian('combinatorial') >>> graph.L.toarray() array([[ 2. , -2. , 0. ], @@ -1357,24 +1350,22 @@ def get_edge_list(self): Edge list of a directed graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 0, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1, 1], [1, 0, 2], [3, 3, 4]) Edge list of an undirected graph. - >>> adjacency = [ + >>> graph = graphs.Graph([ ... [0, 3, 0], ... [3, 0, 4], ... [0, 4, 0], - ... ] - >>> graph = graphs.Graph(adjacency) + ... ]) >>> sources, targets, weights = graph.get_edge_list() >>> list(sources), list(targets), list(weights) ([0, 1], [1, 2], [3, 4]) From 2ae4735d7b736a79104b33ef6257deb37b7d5279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 02:47:47 +0100 Subject: [PATCH 302/365] fix python 2.7 --- pygsp/graphs/graph.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ed52b0b7..9534b927 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -634,16 +634,16 @@ def subgraph(self, vertices): Examples -------- >>> graph = graphs.Graph([ - ... [0, 3, 0, 0], - ... [3, 0, 4, 0], - ... [0, 4, 0, 2], - ... [0, 0, 2, 0], + ... [0., 3., 0., 0.], + ... [3., 0., 4., 0.], + ... [0., 4., 0., 2.], + ... [0., 0., 2., 0.], ... ]) >>> graph = graph.subgraph([0, 2, 1]) >>> graph.W.toarray() - array([[0, 0, 3], - [0, 0, 4], - [3, 4, 0]], dtype=int64) + array([[0., 0., 3.], + [0., 0., 4.], + [3., 4., 0.]]) """ adjacency = self.W[vertices, :][:, vertices] From 4a21884a711d752815e6354887a3b49694398381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Mar 2019 04:07:06 +0100 Subject: [PATCH 303/365] improve graph construction from adjacency --- pygsp/graphs/graph.py | 392 ++----------------------------------- pygsp/graphs/randomring.py | 2 +- pygsp/tests/test_graphs.py | 110 ----------- 3 files changed, 20 insertions(+), 484 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9534b927..6d0620a1 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -51,8 +51,6 @@ class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): The graph Laplacian, an N-by-N matrix computed from W. lap_type : 'normalized', 'combinatorial' The kind of Laplacian that was computed by :func:`compute_laplacian`. - signals : dict (string -> :class:`numpy.ndarray`) - Signals attached to the graph. coords : :class:`numpy.ndarray` Vertices coordinates in 2D or 3D space. Used for plotting only. plotting : dict @@ -77,7 +75,7 @@ class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): [2., 0., 5.], [0., 5., 0.]]) >>> graph.d - array([1, 2, 1], dtype=int32) + array([1, 2, 1]) >>> graph.dw array([2., 7., 5.]) >>> graph.L.toarray() @@ -102,34 +100,32 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.logger = utils.build_logger(__name__) - # CSR sparse matrices are the most efficient for matrix multiplication. - # They are the sole sparse matrix type to support eliminate_zeros(). - if sparse.isspmatrix_csr(W): - self.W = W - elif sparse.isspmatrix(W): - self.W = W.tocsr() - else: - self.W = sparse.csr_matrix(np.asanyarray(W)) + if not sparse.isspmatrix(adjacency): + adjacency = np.asanyarray(adjacency) - if len(self.W.shape) != 2 or self.W.shape[0] != self.W.shape[1]: - raise ValueError('W has incorrect shape {}'.format(self.W.shape)) + if (adjacency.ndim != 2) or (adjacency.shape[0] != adjacency.shape[1]): + raise ValueError('Adjacency: must be a square matrix.') - self.n_vertices = self.W.shape[0] + # CSR sparse matrices are the most efficient for matrix multiplication. + # They are the sole sparse matrix type to support eliminate_zeros(). + self.W = sparse.csr_matrix(adjacency, copy=False) - if np.isnan(self._adjacency.sum()): + if np.isnan(self.W.sum()): raise ValueError('Adjacency: there is a Not a Number (NaN).') - if np.isinf(self._adjacency.sum()): + if np.isinf(self.W.sum()): raise ValueError('Adjacency: there is an infinite value.') if self.has_loops(): self.logger.warning('Adjacency: there are self-loops ' '(non-zeros on the diagonal). ' 'The Laplacian will not see them.') - if (self._adjacency < 0).nnz != 0: + if (self.W < 0).nnz != 0: self.logger.warning('Adjacency: there are negative edge weights.') - # TODO: why would we ever want this? - # For large matrices it slows the graph construction by a factor 100. - # self.W = sparse.lil_matrix(self.W) + self.n_vertices = self.W.shape[0] + + # Don't keep edges of 0 weight. Otherwise n_edges will not correspond + # to the real number of edges. Problematic when plotting. + self.W.eliminate_zeros() # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. @@ -140,8 +136,9 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, off_diagonal = self._adjacency.nnz - diagonal self.n_edges = off_diagonal // 2 + diagonal + self.compute_laplacian(lap_type) + if coords is not None: - # TODO: self.coords should be None if unset. self.coords = np.asanyarray(coords) self.plotting = {'vertex_size': 100, @@ -172,357 +169,6 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def to_networkx(self): - r"""Export the graph to an `Networkx `_ object - - The weights are stored as an edge attribute under the name `weight`. - The signals are stored as node attributes under the name given when - adding them with :meth:`set_signal`. - - Returns - ------- - graph_nx : :py:class:`networkx.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> nx_graph = graph.to_networkx() - >>> print(nx_graph.number_of_nodes()) - 1130 - - """ - import networkx as nx - graph_nx = nx.from_scipy_sparse_matrix( - self.W, create_using=nx.DiGraph() - if self.is_directed() else nx.Graph(), - edge_attribute='weight') - - for name, signal in self.signals.items(): - # networkx can't work with numpy floats so we convert the singal into python float - signal_dict = {i: float(signal[i]) for i in range(self.N)} - nx.set_node_attributes(graph_nx, signal_dict, name) - return graph_nx - - def to_graphtool(self): - r"""Export the graph to an `Graph tool `_ object - - The weights of the graph are stored in a `property maps `_ under the name `weight` - - Returns - ------- - graph_gt : :py:class:`graph_tool.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> gt_graph = graph.to_graphtool() - >>> weight_property = gt_graph.edge_properties["weight"] - - """ - import graph_tool - graph_gt = graph_tool.Graph(directed=self.is_directed()) - v_in, v_out, weights = self.get_edge_list() - graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - weight_type_str = utils.numpy2graph_tool_type(weights.dtype) - if weight_type_str is None: - raise ValueError("Type {} for the weights is not supported" - .format(str(weights.dtype))) - edge_weight = graph_gt.new_edge_property(weight_type_str) - edge_weight.a = weights - graph_gt.edge_properties['weight'] = edge_weight - for name in self.signals: - edge_type_str = utils.numpy2graph_tool_type(weights.dtype) - if edge_type_str is None: - raise ValueError("Type {} from signal {} is not supported" - .format(str(self.signals[name].dtype), name)) - vprop_double = graph_gt.new_vertex_property(edge_type_str) - vprop_double.get_array()[:] = self.signals[name] - graph_gt.vertex_properties[name] = vprop_double - return graph_gt - - @classmethod - def from_networkx(cls, graph_nx, weight='weight'): - r"""Build a graph from a Networkx object. - - The nodes are ordered according to method `nodes()` from networkx - - When a node attribute is not present for node a value of zero is assign - to the corresponding signal on that node. - - When the networkx graph is an instance of :py:class:`networkx.MultiGraph`, - multiple edge are aggregated by summation. - - Parameters - ---------- - graph_nx : :py:class:`networkx.Graph` - A networkx instance of a graph - weight : (string or None optional (default=’weight’)) - The edge attribute that holds the numerical value used for the edge weight. - If None then all edge weights are 1. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> import networkx as nx - >>> nx_graph = nx.star_graph(200) - >>> graph = graphs.Graph.from_networkx(nx_graph) - - """ - import networkx as nx - # keep a consistent order of nodes for the agency matrix and the signal array - nodelist = graph_nx.nodes() - adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) - graph = cls(adjacency) - # Adding the signals - signals = dict() - for i, node in enumerate(nodelist): - signals_name = graph_nx.nodes[node].keys() - - # Add signal previously not present in the dict of signal - # Set to zero the value of the signal when not present for a node - # in Networkx - for signal in set(signals_name) - set(signals.keys()): - signals[signal] = np.zeros(len(nodelist)) - - # Set the value of the signal - for signal in signals_name: - signals[signal][i] = graph_nx.nodes[node][signal] - - graph.signals = signals - return graph - - @classmethod - def from_graphtool(cls, graph_gt, weight='weight'): - r"""Build a graph from a graph tool object. - - When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. - - Parameters - ---------- - graph_gt : :py:class:`graph_tool.Graph` - Graph tool object - weight : string - Name of the `property `_ - to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. - On the other hand if the property is found but not set for a specific edge the weight of zero will be set - therefore for single edge this will result in a none existing edge. If you want to set to a default value please - use `set_value `_ - from the graph_tool object. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - The weight of the graph are loaded from the edge property named ``edge_prop_name`` - - Examples - -------- - >>> from graph_tool.all import Graph - >>> gt_graph = Graph() - >>> _ = gt_graph.add_vertex(10) - >>> graph = graphs.Graph.from_graphtool(gt_graph) - - """ - import graph_tool as gt - import graph_tool.spectral - - weight_property = graph_gt.edge_properties.get(weight, None) - graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) - - # Adding signals - for signal_name, signal_gt in graph_gt.vertex_properties.items(): - signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) - graph.set_signal(signal, signal_name) - return graph - - @classmethod - def load(cls, path, fmt='auto', backend='auto'): - r"""Load a graph from a file using networkx for import. - The format is guessed from path, or can be specified by fmt - - Parameters - ---------- - path : String - Where the file is located on the disk. - fmt : {'graphml', 'gml', 'gexf', 'auto'} - Format in which the graph is encoded. - backend : String - Python library used in background to load the graph. - Supported library are networkx and graph_tool - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> graphs.Logo().save('logo.graphml') - >>> graph = graphs.Graph.load('logo.graphml') - - """ - - def load_networkx(saved_path, format): - import networkx as nx - load = getattr(nx, 'read_' + format) - return cls.from_networkx(load(saved_path)) - - def load_graph_tool(saved_path, format): - import graph_tool as gt - graph_gt = gt.load_graph(saved_path, fmt=format) - return cls.from_graphtool(graph_gt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError( - 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - return locals()['load_' + backend](path, fmt) - - def save(self, path, fmt='auto', backend='auto'): - r"""Save the graph into a file - - Parameters - ---------- - path : String - Where to save file on the disk. - fmt : String - Format in which the graph will be encoded. The format is guessed from - the `path` extention when fmt is set to 'auto' - Currently supported format are: - ['graphml', 'gml', 'gexf'] - backend : String - Python library used in background to save the graph. - Supported library are networkx and graph_tool - WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. - - Examples - -------- - >>> graph = graphs.Logo() - >>> graph.save('logo.graphml') - - """ - def save_networkx(graph, save_path): - import networkx as nx - graph_nx = graph.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph_nx, save_path) - - def save_graph_tool(graph, save_path): - graph_gt = graph.to_graphtool() - graph_gt.save(save_path, fmt=fmt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - locals()['save_' + backend](self, path) - - def set_signal(self, signal, name): - r""" - Add or modify a signal to the graph - - Parameters - ---------- - signal : numpy.array - An array mapping from node to his value. For example the value of the signal at node i is signal[i] - name : String - Name associated to the signal. - - Examples - -------- - >>> graph = graphs.Logo() - >>> DELTAS = [20, 30, 1090] - >>> signal = np.zeros(graph.N) - >>> signal[DELTAS] = 1 - >>> graph.set_signal(signal, 'diffusion') - - """ - if len(signal) != self.N: - raise ValueError("A value must be attached to every vertex in the graph") - self.signals[name] = np.asarray(signal) - - def check_weights(self): - r"""Check the characteristics of the weights matrix. - - Returns - ------- - A dict of bools containing informations about the matrix - - has_inf_val : bool - True if the matrix has infinite values else false - has_nan_value : bool - True if the matrix has a "not a number" value else false - is_not_square : bool - True if the matrix is not square else false - diag_is_not_zero : bool - True if the matrix diagonal has not only zeros else false - - Examples - -------- - >>> W = np.arange(4).reshape(2, 2) - >>> G = graphs.Graph(W) - >>> cw = G.check_weights() - >>> cw == {'has_inf_val': False, 'has_nan_value': False, - ... 'is_not_square': False, 'diag_is_not_zero': True} - True - - """ - - has_inf_val = False - diag_is_not_zero = False - is_not_square = False - has_nan_value = False - - if np.isinf(self.W.sum()): - self.logger.warning('There is an infinite ' - 'value in the weight matrix!') - has_inf_val = True - - if abs(self.W.diagonal()).sum() != 0: - self.logger.warning('The main diagonal of ' - 'the weight matrix is not 0!') - diag_is_not_zero = True - - if self.W.get_shape()[0] != self.W.get_shape()[1]: - self.logger.warning('The weight matrix is not square!') - is_not_square = True - - if np.isnan(self.W.sum()): - self.logger.warning('There is a NaN value in the weight matrix!') - has_nan_value = True - - return {'has_inf_val': has_inf_val, - 'has_nan_value': has_nan_value, - 'is_not_square': is_not_square, - 'diag_is_not_zero': diag_is_not_zero} - def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). @@ -741,7 +387,7 @@ def is_directed(self): Returns ------- directed : bool - True if the graph is directed. + True if the graph is directed, False otherwise. Examples -------- diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index a28a11c0..1c24a1d5 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -74,7 +74,7 @@ def __init__(self, N=64, angles=None, seed=None, **kwargs): plotting = {'limits': np.array([-1, 1, -1, 1])} # TODO: save angle and 2D position as graph signals - super(RandomRing, self).__init__(W=W, coords=coords, plotting=plotting, + super(RandomRing, self).__init__(W, coords=coords, plotting=plotting, **kwargs) def _get_extra_repr(self): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 456131bc..7512d68d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -78,10 +78,6 @@ def test_graph(self): graphs.Graph([[0, -1], [-1, 0]]) with self.assertLogs(level='WARNING'): graphs.Graph([[1, 1], [1, 0]]) - for attr in ['A', 'd', 'dw', 'lmax', 'U', 'e', 'coherence', 'D']: - # FIXME: The Laplacian L should be there as well. - self.assertRaises(AttributeError, setattr, G, attr, None) - self.assertRaises(AttributeError, delattr, G, attr) def test_degree(self): graph = graphs.Graph([ @@ -492,112 +488,7 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') -<<<<<<< HEAD - def test_nngraph(self, n_vertices=24): - """Test all the combinations of metric, kind, backend.""" - Graph = graphs.NNGraph - data = np.random.RandomState(42).uniform(size=(n_vertices, 3)) - metrics = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - backends = ['scipy-kdtree', 'scipy-ckdtree', 'flann', 'nmslib'] - - for metric in metrics: - for kind in ['knn', 'radius']: - params = dict(features=data, metric=metric, kind=kind, k=4) - ref = Graph(backend='scipy-pdist', **params) - for backend in backends: - # Unsupported combinations. - if backend == 'flann' and metric == 'max_dist': - self.assertRaises(ValueError, Graph, data, - metric=metric, backend=backend) - elif backend == 'nmslib' and metric == 'minkowski': - self.assertRaises(ValueError, Graph, data, - metric=metric, backend=backend) - elif backend == 'nmslib' and kind == 'radius': - self.assertRaises(ValueError, Graph, data, - kind=kind, backend=backend) - else: - params['backend'] = backend - if backend == 'flann': - graph = Graph(random_seed=40, **params) - else: - graph = Graph(**params) - np.testing.assert_allclose(graph.W.toarray(), - ref.W.toarray(), rtol=1e-5) - - # Distances between points on a circle. - angles = [0, 2 * np.pi / n_vertices] - points = np.stack([np.cos(angles), np.sin(angles)], axis=1) - distance = np.linalg.norm(points[0] - points[1]) - weight = np.exp(-np.log(2) * distance**2) - column = np.zeros(n_vertices) - column[1] = weight - column[-1] = weight - adjacency = scipy.linalg.circulant(column) - data = graphs.Ring(n_vertices).coords - for kind in ['knn', 'radius']: - for backend in backends + ['scipy-pdist']: - if backend == 'nmslib' and kind == 'radius': - continue # unsupported - graph = Graph(data, kind=kind, k=2, radius=1.01*distance, - kernel_width=1, backend=backend) - np.testing.assert_allclose(graph.W.toarray(), adjacency) - - graph = Graph(data, kind='radius', radius='estimate') - np.testing.assert_allclose(graph.radius, np.sqrt(8 / n_vertices)) - graph = Graph(data, kind='radius', k=2, radius='estimate-knn') - np.testing.assert_allclose(graph.radius, distance) - - graph = Graph(data, standardize=True) - np.testing.assert_allclose(np.mean(graph.coords, axis=0), 0, atol=1e-7) - np.testing.assert_allclose(np.std(graph.coords, axis=0), 1) - - # Invalid parameters. - self.assertRaises(ValueError, Graph, np.ones(n_vertices)) - self.assertRaises(ValueError, Graph, np.ones((n_vertices, 3, 4))) - self.assertRaises(ValueError, Graph, data, metric='invalid') - self.assertRaises(ValueError, Graph, data, kind='invalid') - self.assertRaises(ValueError, Graph, data, kernel='invalid') - self.assertRaises(ValueError, Graph, data, backend='invalid') - self.assertRaises(ValueError, Graph, data, kind='knn', k=0) - self.assertRaises(ValueError, Graph, data, kind='knn', k=n_vertices) - self.assertRaises(ValueError, Graph, data, kind='radius', radius=0) - - # Empty graph. - if sys.version_info > (3, 4): # no assertLogs in python 2.7 - for backend in backends + ['scipy-pdist']: - if backend == 'nmslib': - continue # nmslib doesn't support radius - with self.assertLogs(level='WARNING'): - graph = Graph(data, kind='radius', radius=1e-9, - backend=backend) - self.assertEqual(graph.n_edges, 0) - - # Backend parameters. - Graph(data, lap_type='normalized') - Graph(data, plotting=dict(vertex_size=10)) - Graph(data, backend='flann', algorithm='kmeans') - Graph(data, backend='nmslib', method='vptree') - Graph(data, backend='nmslib', index=dict(post=2)) - Graph(data, backend='nmslib', query=dict(efSearch=100)) - for backend in ['scipy-kdtree', 'scipy-ckdtree']: - Graph(data, backend=backend, eps=1e-2) - Graph(data, backend=backend, leafsize=9) - self.assertRaises(ValueError, Graph, data, backend='scipy-pdist', a=0) - - # Kernels. - for name, kernel in graphs.NNGraph._kernels.items(): - similarity = 0 if name == 'rectangular' else 0.5 - np.testing.assert_allclose(kernel(np.ones(10)), similarity) - np.testing.assert_allclose(kernel(np.zeros(10)), 1) - Graph(data, kernel=lambda d: d.min()/d) - if sys.version_info > (3, 4): # no assertLogs in python 2.7 - with self.assertLogs(level='WARNING'): - Graph(data, kernel=lambda d: 1/d) - # Attributes. - self.assertEqual(Graph(data, kind='knn').radius, None) - self.assertEqual(Graph(data, kind='radius').k, None) -======= def test_subgraph(self, n_vertices=100): graph = self._G.subgraph(range(n_vertices)) self.assertEqual(graph.n_vertices, n_vertices) @@ -622,7 +513,6 @@ def test_nngraph(self, n_vertices=30): if dist_type != 'max_dist': graphs.NNGraph(Xin, use_flann=True, NNtype='knn', dist_type=dist_type) ->>>>>>> improve and test subgraph def test_bunny(self): graphs.Bunny() From ca703aca9e7203c0acbfd1fcd95e5ee7d510d27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:08:45 +0100 Subject: [PATCH 304/365] fix bug in weighted degree (had no impact) --- pygsp/graphs/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6d0620a1..479bf31d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -835,7 +835,7 @@ def dw(self): [0.5 2.5 2. ] """ - if self._dw is None: + if not hasattr(self, '_dw'): if not self.is_directed(): # Shortcut for undirected graphs. self._dw = np.ravel(self.W.sum(axis=0)) From 67ab369325f810a4cce9034f108ffe478fc57358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:41:06 +0100 Subject: [PATCH 305/365] improve degree * non-weigthed degree for directed graphs * documentation for non-weigthed degree * better examples * better tests --- pygsp/graphs/graph.py | 8 +++---- pygsp/tests/test_graphs.py | 47 -------------------------------------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 479bf31d..7b260208 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -import warnings -from itertools import groupby +from __future__ import division + from collections import Counter import numpy as np @@ -75,7 +75,7 @@ class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): [2., 0., 5.], [0., 5., 0.]]) >>> graph.d - array([1, 2, 1]) + array([1, 2, 1], dtype=int32) >>> graph.dw array([2., 7., 5.]) >>> graph.L.toarray() @@ -777,7 +777,7 @@ def d(self): [0.5 2.5 2. ] """ - if self._d is None: + if not hasattr(self, '_d'): if not self.is_directed(): # Shortcut for undirected graphs. self._d = self.W.getnnz(axis=1) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 7512d68d..95dac40d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -127,53 +127,6 @@ def test_is_connected(self): self.assertEqual(graph.is_directed(), True) self.assertEqual(graph.is_connected(), True) - def test_is_directed(self): - graph = graphs.Graph([ - [0, 3, 0, 0], - [3, 0, 4, 0], - [0, 4, 0, 2], - [0, 0, 2, 0], - ]) - assert graph.W.nnz == 6 - self.assertEqual(graph.is_directed(), False) - # In-place modification is not allowed anymore. - # graph.W[0, 1] = 0 - # assert graph.W.nnz == 6 - # self.assertEqual(graph.is_directed(recompute=True), True) - # graph.W[1, 0] = 0 - # assert graph.W.nnz == 6 - # self.assertEqual(graph.is_directed(recompute=True), False) - - def test_is_connected(self): - graph = graphs.Graph([ - [0, 1, 0], - [1, 0, 2], - [0, 2, 0], - ]) - self.assertEqual(graph.is_directed(), False) - self.assertEqual(graph.is_connected(), True) - graph = graphs.Graph([ - [0, 1, 0], - [1, 0, 0], - [0, 2, 0], - ]) - self.assertEqual(graph.is_directed(), True) - self.assertEqual(graph.is_connected(), False) - graph = graphs.Graph([ - [0, 1, 0], - [1, 0, 0], - [0, 0, 0], - ]) - self.assertEqual(graph.is_directed(), False) - self.assertEqual(graph.is_connected(), False) - graph = graphs.Graph([ - [0, 1, 0], - [0, 0, 2], - [3, 0, 0], - ]) - self.assertEqual(graph.is_directed(), True) - self.assertEqual(graph.is_connected(), True) - def test_is_directed(self): graph = graphs.Graph([ [0, 3, 0, 0], From b60c8f15e9e859011bbd1024f477912fdc5cb9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 12:51:11 +0100 Subject: [PATCH 306/365] changing the Laplacian invalidates Fourier and difference --- pygsp/filters/filter.py | 2 +- pygsp/graphs/fourier.py | 3 ++- pygsp/graphs/graph.py | 23 ++++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index d7d71528..418457c9 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -642,7 +642,7 @@ def complement(self, frame_bound=None): >>> g += g.complement() >>> A, B = g.estimate_frame_bounds() >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.971, B=1.971 + A=1.972, B=1.972 >>> fig, ax = g.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 0525bf69..108f658c 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -156,7 +156,8 @@ def compute_fourier_basis(self, n_eigenvectors=None): if n_eigenvectors is None: n_eigenvectors = self.n_vertices - if (self._U is not None and n_eigenvectors <= len(self._e)): + if (self._e is not None and self._U is not None and not recompute + and n_eigenvectors <= len(self._e)): return assert self.L.shape == (self.n_vertices, self.n_vertices) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7b260208..df4d3438 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -136,8 +136,6 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, off_diagonal = self._adjacency.nnz - diagonal self.n_edges = off_diagonal // 2 + diagonal - self.compute_laplacian(lap_type) - if coords is not None: self.coords = np.asanyarray(coords) @@ -151,6 +149,21 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.plotting.update(plotting) self.signals = dict() + # Attributes that are lazily computed. + self._A = None + self._d = None + self._dw = None + self._lmax = None + self._U = None + self._e = None + self._coherence = None + self._D = None + # self._L = None + + # TODO: what about Laplacian? Lazy as Fourier, or disallow change? + self.lap_type = lap_type + self.compute_laplacian(lap_type) + # TODO: kept for backward compatibility. self.Ne = self.n_edges self.N = self.n_vertices @@ -777,7 +790,7 @@ def d(self): [0.5 2.5 2. ] """ - if not hasattr(self, '_d'): + if self._d is None: if not self.is_directed(): # Shortcut for undirected graphs. self._d = self.W.getnnz(axis=1) @@ -835,7 +848,7 @@ def dw(self): [0.5 2.5 2. ] """ - if not hasattr(self, '_dw'): + if self._dw is None: if not self.is_directed(): # Shortcut for undirected graphs. self._dw = np.ravel(self.W.sum(axis=0)) @@ -906,7 +919,7 @@ def estimate_lmax(self, method='lanczos'): 18.58 """ - if method == self._lmax_method: + if self._lmax is not None and not recompute: return self._lmax_method = method From 723491a2cd2be56983e8078f5ad97bdc1aadbb2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 5 Mar 2019 13:52:00 +0100 Subject: [PATCH 307/365] in-place modification of the graph is not allowed anymore --- pygsp/filters/filter.py | 2 +- pygsp/graphs/fourier.py | 3 +-- pygsp/graphs/graph.py | 32 +++++++++++++--------------- pygsp/graphs/stochasticblockmodel.py | 4 +--- pygsp/tests/test_graphs.py | 17 +++++++++------ 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 418457c9..d7d71528 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -642,7 +642,7 @@ def complement(self, frame_bound=None): >>> g += g.complement() >>> A, B = g.estimate_frame_bounds() >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=1.972, B=1.972 + A=1.971, B=1.971 >>> fig, ax = g.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 108f658c..0525bf69 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -156,8 +156,7 @@ def compute_fourier_basis(self, n_eigenvectors=None): if n_eigenvectors is None: n_eigenvectors = self.n_vertices - if (self._e is not None and self._U is not None and not recompute - and n_eigenvectors <= len(self._e)): + if (self._U is not None and n_eigenvectors <= len(self._e)): return assert self.L.shape == (self.n_vertices, self.n_vertices) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index df4d3438..59515288 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -108,24 +108,27 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, # CSR sparse matrices are the most efficient for matrix multiplication. # They are the sole sparse matrix type to support eliminate_zeros(). - self.W = sparse.csr_matrix(adjacency, copy=False) + self._adjacency = sparse.csr_matrix(adjacency, copy=False) - if np.isnan(self.W.sum()): + if np.isnan(self._adjacency.sum()): raise ValueError('Adjacency: there is a Not a Number (NaN).') - if np.isinf(self.W.sum()): + if np.isinf(self._adjacency.sum()): raise ValueError('Adjacency: there is an infinite value.') if self.has_loops(): self.logger.warning('Adjacency: there are self-loops ' '(non-zeros on the diagonal). ' 'The Laplacian will not see them.') - if (self.W < 0).nnz != 0: + if (self._adjacency < 0).nnz != 0: self.logger.warning('Adjacency: there are negative edge weights.') - self.n_vertices = self.W.shape[0] + self.n_vertices = self._adjacency.shape[0] # Don't keep edges of 0 weight. Otherwise n_edges will not correspond # to the real number of edges. Problematic when plotting. - self.W.eliminate_zeros() + self._adjacency.eliminate_zeros() + + self._directed = None + self._connected = None # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. @@ -137,6 +140,7 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self.n_edges = off_diagonal // 2 + diagonal if coords is not None: + # TODO: self.coords should be None if unset. self.coords = np.asanyarray(coords) self.plotting = {'vertex_size': 100, @@ -154,6 +158,7 @@ def __init__(self, adjacency, lap_type='combinatorial', coords=None, self._d = None self._dw = None self._lmax = None + self._lmax_method = None self._U = None self._e = None self._coherence = None @@ -362,7 +367,7 @@ def is_connected(self): return self._connected adjacencies = [self.W] - if self.is_directed(recompute=recompute): + if self.is_directed(): adjacencies.append(self.W.T) for adjacency in adjacencies: @@ -392,11 +397,6 @@ def is_directed(self): In this framework, we consider that a graph is directed if and only if its weight matrix is not symmetric. - Parameters - ---------- - recompute : bool - Force to recompute the directedness if already known. - Returns ------- directed : bool @@ -426,10 +426,8 @@ def is_directed(self): False """ - if hasattr(self, '_directed') and not recompute: - return self._directed - - self._directed = (self.W != self.W.T).nnz != 0 + if self._directed is None: + self._directed = (self.W != self.W.T).nnz != 0 return self._directed def has_loops(self): @@ -919,7 +917,7 @@ def estimate_lmax(self, method='lanczos'): 18.58 """ - if self._lmax is not None and not recompute: + if method == self._lmax_method: return self._lmax_method = method diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 409534cb..63918b72 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -133,9 +133,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not connected: break - self.W = W - self.n_vertices = W.shape[0] - if self.is_connected(recompute=True): + if Graph(W).is_connected(): break if n_try is not None: n_try -= 1 diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 95dac40d..16f2ee0d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -78,6 +78,10 @@ def test_graph(self): graphs.Graph([[0, -1], [-1, 0]]) with self.assertLogs(level='WARNING'): graphs.Graph([[1, 1], [1, 0]]) + for attr in ['A', 'd', 'dw', 'lmax', 'U', 'e', 'coherence', 'D']: + # FIXME: The Laplacian L should be there as well. + self.assertRaises(AttributeError, setattr, G, attr, None) + self.assertRaises(AttributeError, delattr, G, attr) def test_degree(self): graph = graphs.Graph([ @@ -136,12 +140,13 @@ def test_is_directed(self): ]) assert graph.W.nnz == 6 self.assertEqual(graph.is_directed(), False) - graph.W[0, 1] = 0 - assert graph.W.nnz == 6 - self.assertEqual(graph.is_directed(recompute=True), True) - graph.W[1, 0] = 0 - assert graph.W.nnz == 6 - self.assertEqual(graph.is_directed(recompute=True), False) + # In-place modification is not allowed anymore. + # graph.W[0, 1] = 0 + # assert graph.W.nnz == 6 + # self.assertEqual(graph.is_directed(recompute=True), True) + # graph.W[1, 0] = 0 + # assert graph.W.nnz == 6 + # self.assertEqual(graph.is_directed(recompute=True), False) def test_laplacian(self): From 17ad36d532c786e1941ec386bde02bb5dd8cda25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 00:45:03 +0100 Subject: [PATCH 308/365] doc: update Fourier coherence --- pygsp/graphs/fourier.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 0525bf69..394008b3 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -85,7 +85,11 @@ def coherence(self): >>> graph.compute_fourier_basis() >>> minimum = 1 / np.sqrt(graph.n_vertices) >>> print('{:.2f} in [{:.2f}, 1]'.format(graph.coherence, minimum)) +<<<<<<< HEAD 0.91 in [0.12, 1] +======= + 0.75 in [0.12, 1] +>>>>>>> doc: update Fourier coherence >>> >>> # Plot the most localized eigenvector. >>> import matplotlib.pyplot as plt From cbecfa82ddc598820c23a93b9c3f229ec5a88eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 15:50:16 +0100 Subject: [PATCH 309/365] improve and test set_signal --- pygsp/graphs/graph.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 59515288..a8de972b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -51,6 +51,8 @@ class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): The graph Laplacian, an N-by-N matrix computed from W. lap_type : 'normalized', 'combinatorial' The kind of Laplacian that was computed by :func:`compute_laplacian`. + signals : dict (string -> :class:`numpy.ndarray`) + Signals attached to the graph. coords : :class:`numpy.ndarray` Vertices coordinates in 2D or 3D space. Used for plotting only. plotting : dict @@ -655,7 +657,7 @@ def compute_laplacian(self, lap_type='combinatorial'): def _check_signal(self, s): r"""Check if signal is valid.""" s = np.asanyarray(s) - if s.shape[0] != self.N: + if s.shape[0] != self.n_vertices: raise ValueError('First dimension must be the number of vertices ' 'G.N = {}, got {}.'.format(self.N, s.shape)) return s From 987a0d156cbce6f227706c7f88f906c760455731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:14:37 +0100 Subject: [PATCH 310/365] add new methods to doc --- pygsp/graphs/__init__.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 8e1b327b..4f406c15 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -90,20 +90,6 @@ Import and export (I/O) ----------------------- -We provide import and export facility to two well-known Python packages for -network analysis: NetworkX_ and graph-tool_. -Those packages and the PyGSP are fundamentally different in their goals (graph -analysis versus graph signal analysis) and graph representations (if in the -PyGSP everything is an ndarray, in NetworkX everything is a dictionary). -Those tools are complementary and good interoperability is necessary to exploit -the strengths of each tool. -We ourselves leverage NetworkX and graph-tool to save and load graphs. - -Note: to tie a signal with the graph, such that they are exported together, -attach it first with :meth:`Graph.set_signal`. - -.. _NetworkX: https://networkx.github.io -.. _graph-tool: https://graph-tool.skewed.de .. autosummary:: From 9712b671d35666d4fc919cf2a59c814c533f1958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:18:34 +0100 Subject: [PATCH 311/365] remove redundant :py directive --- pygsp/graphs/graph.py | 1 + pygsp/utils.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index a8de972b..40cc32fd 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -189,6 +189,7 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) + def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). diff --git a/pygsp/utils.py b/pygsp/utils.py index 03e4cbdb..2ec9c495 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -374,7 +374,7 @@ def numpy2graph_tool_type(dtype): Parameters ---------- - dtype : :py:class:`numpy.dtype` + dtype : :class:`numpy.dtype` Returns ------- From d2bdec8ce442b12f58ca79c5f2ba0facd320f8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 16:19:30 +0100 Subject: [PATCH 312/365] improve and test convert_dtype --- pygsp/tests/test_utils.py | 6 ++++++ pygsp/utils.py | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index af23f4b8..a7298319 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -32,5 +32,11 @@ def test_symmetrize(self): np.testing.assert_equal(W1.toarray(), W2) self.assertRaises(ValueError, utils.symmetrize, W, 'sum') + def test_convert_dtype(self): + signal = np.zeros(10, dtype=np.int16) + self.assertEqual(utils.convert_dtype(signal.dtype), 'int16_t') + signal = np.zeros(10, dtype=np.float128) + self.assertEqual(utils.convert_dtype(signal.dtype), None) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/utils.py b/pygsp/utils.py index 2ec9c495..70fbfcc9 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -365,25 +365,29 @@ def import_functions(names, src, dst): module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) -def numpy2graph_tool_type(dtype): + +def convert_dtype(dtype): r"""Convert from numpy dtype to graph tool types. - The supported numpy types are: {bool_, int_, int16, int32, int64, - float_, float16, float32, float64} - See graph_tool `doc `_ for more details. + The supported numpy types are: ``bool_``, ``int_``, ``int16``, ``int32``, + ``int64``, ``float_``, ``float16``, ``float32``, ``float64``. + + See the `graph-tool documentation + `_ + for details. Parameters ---------- dtype : :class:`numpy.dtype` + Numpy data type. Returns ------- - graph_tool_type : string - A string representing the type ready to be use by graph_tool + type : string + A string representing the type, ready to be used by graph-tool. """ - # Encode the numpy types with its correspondence in graph_tool - numpy2gt_type = { + translation = { np.bool_: 'bool', np.int_: 'int', np.int16: 'int16_t', @@ -396,8 +400,8 @@ def numpy2graph_tool_type(dtype): } try: - graph_tool_type = numpy2gt_type[dtype.type] - except: + graph_tool_type = translation[dtype.type] + except KeyError: graph_tool_type = None return graph_tool_type From 38a3eb99ebbc27f4e9ea7af5938d0c287d523d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Mar 2019 19:48:57 +0100 Subject: [PATCH 313/365] tests: cleaner skipping --- pygsp/tests/test_graphs.py | 73 ++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 16f2ee0d..a00fd4f9 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -609,19 +609,17 @@ def test_networkx_import_export(self): np.testing.assert_array_equal(nx.adjacency_matrix(g_nx).todense(), nx.adjacency_matrix(g).todense()) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_export_import(self): # Export to graph tool and reimport to PyGSP directly # The exported graph is a simple one without an associated Signal - if sys.version_info < (3, 0): - return None # skip test for python 2.7 g = graphs.Bunny() g_gt = g.to_graphtool() g2 = graphs.Graph.from_graphtool(g_gt) np.testing.assert_array_equal(g.W.todense(), g2.W.todense()) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_multiedge_import(self): - if sys.version_info < (3, 0): - return None # skip test for python2.7 # Manualy create a graph with multiple edges g_gt = gt.Graph() g_gt.add_vertex(10) @@ -642,11 +640,10 @@ def test_graphtool_multiedge_import(self): g3 = graphs.Graph.from_graphtool(g_gt) self.assertEqual(g3.W[3, 6], 9.0) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_import_export(self): # Import to PyGSP and export again to graph tool directly # create a random graphTool graph that does not contain multiple edges and no signal - if sys.version_info < (3, 0): - return None # skip test for python2.7 graph_gt = gt.generation.random_graph(100, lambda : (np.random.poisson(4), np.random.poisson(4))) eprop_double = graph_gt.new_edge_property("double") @@ -692,9 +689,8 @@ def test_graphtool_signal_export(self): self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) + @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_signal_import(self): - if sys.version_info < (3, 0): - return None # skip test for python2.7 g_gt = gt.Graph() g_gt.add_vertex(10) @@ -733,37 +729,38 @@ def test_networkx_signal_import(self): self.assertEqual(g.signals["signal1"][i], nx.get_node_attributes(g_nx, "signal1")[node]) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): - if sys.version_info >= (3, 6): - graph = graphs.Sensor(seed=42) - np.random.seed(42) - signal = np.random.random(graph.N) - graph.set_signal(signal, "signal") - - # save - nx_gt = ['gml', 'graphml'] - all_files = [] - for fmt in nx_gt: - all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph_tool') - graph.save("graph_nx.{}".format(fmt), backend='networkx') - graph.save("graph_nx.{}".format('gexf'), backend='networkx') - all_files += ["graph_nx.{}".format('gexf')] - - # load - for filename in all_files: - if not "_gt" in filename: - graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') - np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) - if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) - np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) - - # clean - for filename in all_files: - os.remove(filename) + + graph = graphs.Sensor(seed=42) + np.random.seed(42) + signal = np.random.random(graph.N) + graph.set_signal(signal, "signal") + + # save + nx_gt = ['gml', 'graphml'] + all_files = [] + for fmt in nx_gt: + all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] + graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_nx.{}".format(fmt), backend='networkx') + graph.save("graph_nx.{}".format('gexf'), backend='networkx') + all_files += ["graph_nx.{}".format('gexf')] + + # load + for filename in all_files: + if not "_gt" in filename: + graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') + np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) + np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) + if not ".gexf" in filename: + graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) + np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) + + # clean + for filename in all_files: + os.remove(filename) suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) From ed55ecccd2bef72c13e7edf753c4cfd1a61d2aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 00:32:10 +0100 Subject: [PATCH 314/365] show helpful message if networkx or graph-tool cannot be imported --- pygsp/graphs/graph.py | 299 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 298 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 40cc32fd..71c99927 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -14,7 +14,26 @@ from ._layout import LayoutMixIn -class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): +def _import_networkx(): + try: + import networkx as nx + except Exception as e: + raise ImportError('Cannot import networkx. Use graph-tool or try to ' + 'install it with pip (or conda) install networkx. ' + 'Original exception: {}'.format(e)) + return nx + + +def _import_graphtool(): + try: + import graph_tool as gt + except Exception as e: + raise ImportError('Cannot import graph-tool. Use networkx or try to ' + 'install it. Original exception: {}'.format(e)) + return gt + + +class Graph(fourier.GraphFourier, difference.GraphDifference): r"""Base graph class. * Instantiate it to construct a graph from a (weighted) adjacency matrix. @@ -189,6 +208,284 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) +<<<<<<< HEAD +======= +<<<<<<< HEAD + def to_networkx(self): + r"""Export the graph to an `Networkx `_ object + + The weights are stored as an edge attribute under the name `weight`. + The signals are stored as node attributes under the name given when + adding them with :meth:`set_signal`. + + Returns + ------- + graph_nx : :class:`networkx.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> nx_graph = graph.to_networkx() + >>> print(nx_graph.number_of_nodes()) + 1130 + + """ + nx = _import_networkx() + graph_nx = nx.from_scipy_sparse_matrix( + self.W, create_using=nx.DiGraph() + if self.is_directed() else nx.Graph(), + edge_attribute='weight') + + for name, signal in self.signals.items(): + # networkx can't work with numpy floats so we convert the singal into python float + signal_dict = {i: float(signal[i]) for i in range(self.N)} + nx.set_node_attributes(graph_nx, signal_dict, name) + return graph_nx + + def to_graphtool(self): + r"""Export the graph to an `Graph tool `_ object + + The weights of the graph are stored in a `property maps `_ under the name `weight` + + Returns + ------- + graph_gt : :class:`graph_tool.Graph` + + Examples + -------- + >>> graph = graphs.Logo() + >>> gt_graph = graph.to_graphtool() + >>> weight_property = gt_graph.edge_properties["weight"] + + """ + gt = _import_graphtool() + graph_gt = gt.Graph(directed=self.is_directed()) + v_in, v_out, weights = self.get_edge_list() + graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) + weight_type_str = utils.convert_dtype(weights.dtype) + if weight_type_str is None: + raise ValueError("Type {} for the weights is not supported" + .format(str(weights.dtype))) + edge_weight = graph_gt.new_edge_property(weight_type_str) + edge_weight.a = weights + graph_gt.edge_properties['weight'] = edge_weight + for name in self.signals: + edge_type_str = utils.convert_dtype(weights.dtype) + if edge_type_str is None: + raise ValueError("Type {} from signal {} is not supported" + .format(str(self.signals[name].dtype), name)) + vprop_double = graph_gt.new_vertex_property(edge_type_str) + vprop_double.get_array()[:] = self.signals[name] + graph_gt.vertex_properties[name] = vprop_double + return graph_gt + + @classmethod + def from_networkx(cls, graph_nx, weight='weight'): + r"""Build a graph from a Networkx object. + + The nodes are ordered according to method `nodes()` from networkx + + When a node attribute is not present for node a value of zero is assign + to the corresponding signal on that node. + + When the networkx graph is an instance of :class:`networkx.MultiGraph`, + multiple edge are aggregated by summation. + + Parameters + ---------- + graph_nx : :class:`networkx.Graph` + A networkx instance of a graph + weight : (string or None optional (default=’weight’)) + The edge attribute that holds the numerical value used for the edge weight. + If None then all edge weights are 1. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> import networkx as nx + >>> nx_graph = nx.star_graph(200) + >>> graph = graphs.Graph.from_networkx(nx_graph) + + """ + nx = _import_networkx() + # keep a consistent order of nodes for the agency matrix and the signal array + nodelist = graph_nx.nodes() + adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) + graph = cls(adjacency) + # Adding the signals + signals = dict() + for i, node in enumerate(nodelist): + signals_name = graph_nx.nodes[node].keys() + + # Add signal previously not present in the dict of signal + # Set to zero the value of the signal when not present for a node + # in Networkx + for signal in set(signals_name) - set(signals.keys()): + signals[signal] = np.zeros(len(nodelist)) + + # Set the value of the signal + for signal in signals_name: + signals[signal][i] = graph_nx.nodes[node][signal] + + graph.signals = signals + return graph + + @classmethod + def from_graphtool(cls, graph_gt, weight='weight'): + r"""Build a graph from a graph tool object. + + When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. + + Parameters + ---------- + graph_gt : :class:`graph_tool.Graph` + Graph tool object + weight : string + Name of the `property `_ + to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. + On the other hand if the property is found but not set for a specific edge the weight of zero will be set + therefore for single edge this will result in a none existing edge. If you want to set to a default value please + use `set_value `_ + from the graph_tool object. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + The weight of the graph are loaded from the edge property named ``edge_prop_name`` + + Examples + -------- + >>> from graph_tool.all import Graph + >>> gt_graph = Graph() + >>> _ = gt_graph.add_vertex(10) + >>> graph = graphs.Graph.from_graphtool(gt_graph) + + """ + gt = _import_graphtool() + import graph_tool.spectral + + weight_property = graph_gt.edge_properties.get(weight, None) + graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) + + # Adding signals + for signal_name, signal_gt in graph_gt.vertex_properties.items(): + signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) + graph.set_signal(signal, signal_name) + return graph + + @classmethod + def load(cls, path, fmt='auto', backend='auto'): + r"""Load a graph from a file using networkx for import. + The format is guessed from path, or can be specified by fmt + + Parameters + ---------- + path : String + Where the file is located on the disk. + fmt : {'graphml', 'gml', 'gexf', 'auto'} + Format in which the graph is encoded. + backend : String + Python library used in background to load the graph. + Supported library are networkx and graph_tool + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + + Examples + -------- + >>> graphs.Logo().save('logo.graphml') + >>> graph = graphs.Graph.load('logo.graphml') + + """ + + def load_networkx(saved_path, format): + nx = _import_networkx() + load = getattr(nx, 'read_' + format) + return cls.from_networkx(load(saved_path)) + + def load_graph_tool(saved_path, format): + gt = _import_graphtool() + graph_gt = gt.load_graph(saved_path, fmt=format) + return cls.from_graphtool(graph_gt) + + if fmt == 'auto': + fmt = path.split('.')[-1] + + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) + + if backend not in ['networkx', 'graph_tool']: + raise ValueError( + 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) + + return locals()['load_' + backend](path, fmt) + + def save(self, path, fmt='auto', backend='auto'): + r"""Save the graph into a file + + Parameters + ---------- + path : String + Where to save file on the disk. + fmt : String + Format in which the graph will be encoded. The format is guessed from + the `path` extention when fmt is set to 'auto' + Currently supported format are: + ['graphml', 'gml', 'gexf'] + backend : String + Python library used in background to save the graph. + Supported library are networkx and graph_tool + WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') + + """ + def save_networkx(graph, save_path): + nx = _import_networkx() + graph_nx = graph.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph_nx, save_path) + + def save_graph_tool(graph, save_path): + graph_gt = graph.to_graphtool() + graph_gt.save(save_path, fmt=fmt) + + if fmt == 'auto': + fmt = path.split('.')[-1] + + if backend == 'auto': + if fmt in ['graphml', 'gml', 'gexf']: + backend = 'networkx' + else: + backend = 'graph_tool' + + supported_format = ['graphml', 'gml', 'gexf'] + if fmt not in supported_format: + raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) + + if backend not in ['networkx', 'graph_tool']: + raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) + + locals()['save_' + backend](self, path) + + def set_signal(self, signal, name): + r"""Attach a signal to the graph. +>>>>>>> show helpful message if networkx or graph-tool cannot be imported def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). From 3a0b5fe9b955aaa434c94b60cd2c681e1c77da5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 01:22:56 +0100 Subject: [PATCH 315/365] test import errors --- pygsp/graphs/graph.py | 278 ------------------------------------- pygsp/tests/test_graphs.py | 37 ++++- 2 files changed, 33 insertions(+), 282 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 71c99927..723adf0a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -208,284 +208,6 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) -<<<<<<< HEAD -======= -<<<<<<< HEAD - def to_networkx(self): - r"""Export the graph to an `Networkx `_ object - - The weights are stored as an edge attribute under the name `weight`. - The signals are stored as node attributes under the name given when - adding them with :meth:`set_signal`. - - Returns - ------- - graph_nx : :class:`networkx.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> nx_graph = graph.to_networkx() - >>> print(nx_graph.number_of_nodes()) - 1130 - - """ - nx = _import_networkx() - graph_nx = nx.from_scipy_sparse_matrix( - self.W, create_using=nx.DiGraph() - if self.is_directed() else nx.Graph(), - edge_attribute='weight') - - for name, signal in self.signals.items(): - # networkx can't work with numpy floats so we convert the singal into python float - signal_dict = {i: float(signal[i]) for i in range(self.N)} - nx.set_node_attributes(graph_nx, signal_dict, name) - return graph_nx - - def to_graphtool(self): - r"""Export the graph to an `Graph tool `_ object - - The weights of the graph are stored in a `property maps `_ under the name `weight` - - Returns - ------- - graph_gt : :class:`graph_tool.Graph` - - Examples - -------- - >>> graph = graphs.Logo() - >>> gt_graph = graph.to_graphtool() - >>> weight_property = gt_graph.edge_properties["weight"] - - """ - gt = _import_graphtool() - graph_gt = gt.Graph(directed=self.is_directed()) - v_in, v_out, weights = self.get_edge_list() - graph_gt.add_edge_list(np.asarray((v_in, v_out)).T) - weight_type_str = utils.convert_dtype(weights.dtype) - if weight_type_str is None: - raise ValueError("Type {} for the weights is not supported" - .format(str(weights.dtype))) - edge_weight = graph_gt.new_edge_property(weight_type_str) - edge_weight.a = weights - graph_gt.edge_properties['weight'] = edge_weight - for name in self.signals: - edge_type_str = utils.convert_dtype(weights.dtype) - if edge_type_str is None: - raise ValueError("Type {} from signal {} is not supported" - .format(str(self.signals[name].dtype), name)) - vprop_double = graph_gt.new_vertex_property(edge_type_str) - vprop_double.get_array()[:] = self.signals[name] - graph_gt.vertex_properties[name] = vprop_double - return graph_gt - - @classmethod - def from_networkx(cls, graph_nx, weight='weight'): - r"""Build a graph from a Networkx object. - - The nodes are ordered according to method `nodes()` from networkx - - When a node attribute is not present for node a value of zero is assign - to the corresponding signal on that node. - - When the networkx graph is an instance of :class:`networkx.MultiGraph`, - multiple edge are aggregated by summation. - - Parameters - ---------- - graph_nx : :class:`networkx.Graph` - A networkx instance of a graph - weight : (string or None optional (default=’weight’)) - The edge attribute that holds the numerical value used for the edge weight. - If None then all edge weights are 1. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> import networkx as nx - >>> nx_graph = nx.star_graph(200) - >>> graph = graphs.Graph.from_networkx(nx_graph) - - """ - nx = _import_networkx() - # keep a consistent order of nodes for the agency matrix and the signal array - nodelist = graph_nx.nodes() - adjacency = nx.to_scipy_sparse_matrix(graph_nx, nodelist, weight=weight) - graph = cls(adjacency) - # Adding the signals - signals = dict() - for i, node in enumerate(nodelist): - signals_name = graph_nx.nodes[node].keys() - - # Add signal previously not present in the dict of signal - # Set to zero the value of the signal when not present for a node - # in Networkx - for signal in set(signals_name) - set(signals.keys()): - signals[signal] = np.zeros(len(nodelist)) - - # Set the value of the signal - for signal in signals_name: - signals[signal][i] = graph_nx.nodes[node][signal] - - graph.signals = signals - return graph - - @classmethod - def from_graphtool(cls, graph_gt, weight='weight'): - r"""Build a graph from a graph tool object. - - When the graph as multiple edge connecting the same two nodes a sum over the edges is taken to merge them. - - Parameters - ---------- - graph_gt : :class:`graph_tool.Graph` - Graph tool object - weight : string - Name of the `property `_ - to be loaded as weight for the graph. If the property is not found a graph with default weight set to 1 is created. - On the other hand if the property is found but not set for a specific edge the weight of zero will be set - therefore for single edge this will result in a none existing edge. If you want to set to a default value please - use `set_value `_ - from the graph_tool object. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - The weight of the graph are loaded from the edge property named ``edge_prop_name`` - - Examples - -------- - >>> from graph_tool.all import Graph - >>> gt_graph = Graph() - >>> _ = gt_graph.add_vertex(10) - >>> graph = graphs.Graph.from_graphtool(gt_graph) - - """ - gt = _import_graphtool() - import graph_tool.spectral - - weight_property = graph_gt.edge_properties.get(weight, None) - graph = cls(gt.spectral.adjacency(graph_gt, weight=weight_property).todense().T) - - # Adding signals - for signal_name, signal_gt in graph_gt.vertex_properties.items(): - signal = np.array([signal_gt[vertex] for vertex in graph_gt.vertices()]) - graph.set_signal(signal, signal_name) - return graph - - @classmethod - def load(cls, path, fmt='auto', backend='auto'): - r"""Load a graph from a file using networkx for import. - The format is guessed from path, or can be specified by fmt - - Parameters - ---------- - path : String - Where the file is located on the disk. - fmt : {'graphml', 'gml', 'gexf', 'auto'} - Format in which the graph is encoded. - backend : String - Python library used in background to load the graph. - Supported library are networkx and graph_tool - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - - Examples - -------- - >>> graphs.Logo().save('logo.graphml') - >>> graph = graphs.Graph.load('logo.graphml') - - """ - - def load_networkx(saved_path, format): - nx = _import_networkx() - load = getattr(nx, 'read_' + format) - return cls.from_networkx(load(saved_path)) - - def load_graph_tool(saved_path, format): - gt = _import_graphtool() - graph_gt = gt.load_graph(saved_path, fmt=format) - return cls.from_graphtool(graph_gt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError( - 'Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - return locals()['load_' + backend](path, fmt) - - def save(self, path, fmt='auto', backend='auto'): - r"""Save the graph into a file - - Parameters - ---------- - path : String - Where to save file on the disk. - fmt : String - Format in which the graph will be encoded. The format is guessed from - the `path` extention when fmt is set to 'auto' - Currently supported format are: - ['graphml', 'gml', 'gexf'] - backend : String - Python library used in background to save the graph. - Supported library are networkx and graph_tool - WARNING: when using graph_tool as backend the weight of the edges precision is truncated to E-06. - - Examples - -------- - >>> graph = graphs.Logo() - >>> graph.save('logo.graphml') - - """ - def save_networkx(graph, save_path): - nx = _import_networkx() - graph_nx = graph.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph_nx, save_path) - - def save_graph_tool(graph, save_path): - graph_gt = graph.to_graphtool() - graph_gt.save(save_path, fmt=fmt) - - if fmt == 'auto': - fmt = path.split('.')[-1] - - if backend == 'auto': - if fmt in ['graphml', 'gml', 'gexf']: - backend = 'networkx' - else: - backend = 'graph_tool' - - supported_format = ['graphml', 'gml', 'gexf'] - if fmt not in supported_format: - raise ValueError('Unsupported format {}. Please use a format from {}'.format(fmt, supported_format)) - - if backend not in ['networkx', 'graph_tool']: - raise ValueError('Unsupported backend specified {} Please use either networkx or graph_tool.'.format(backend)) - - locals()['save_' + backend](self, path) - - def set_signal(self, signal, name): - r"""Attach a signal to the graph. ->>>>>>> show helpful message if networkx or graph-tool cannot be imported def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a00fd4f9..46b3fdc4 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -7,10 +7,10 @@ from __future__ import division +import os +import random import sys import unittest -import random -import os import numpy as np import scipy.linalg @@ -590,7 +590,7 @@ def test_grid2dimgpatches(self): suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) -class TestCaseImportExport(unittest.TestCase): +class TestImportExport(unittest.TestCase): def test_networkx_export_import(self): # Export to networkx and reimport to PyGSP @@ -763,5 +763,34 @@ def test_save_load(self): os.remove(filename) -suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestCaseImportExport) + @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') + def test_import_errors(self): + from unittest.mock import patch + graph = graphs.Sensor() + filename = 'graph.gml' + with patch.dict(sys.modules, {'networkx': None}): + self.assertRaises(ImportError, graph.to_networkx) + self.assertRaises(ImportError, graphs.Graph.from_networkx, None) + self.assertRaises(ImportError, graph.save, filename, + backend='networkx') + self.assertRaises(ImportError, graphs.Graph.load, filename, + backend='networkx') + graph.save(filename) + graphs.Graph.load(filename) + with patch.dict(sys.modules, {'graph_tool': None}): + self.assertRaises(ImportError, graph.to_graphtool) + self.assertRaises(ImportError, graphs.Graph.from_graphtool, None) + self.assertRaises(ImportError, graph.save, filename, + backend='graph_tool') + self.assertRaises(ImportError, graphs.Graph.load, filename, + backend='graph_tool') + graph.save(filename) + graphs.Graph.load(filename) + with patch.dict(sys.modules, {'networkx': None, 'graph_tool': None}): + self.assertRaises(ImportError, graph.save, filename) + self.assertRaises(ImportError, graphs.Graph.load, filename) + os.remove(filename) + + +suite_import_export = unittest.TestLoader().loadTestsFromTestCase(TestImportExport) suite = unittest.TestSuite([suite_graphs, suite_import_export]) From 7895a5eb77cb15d8a66e5d3fa62f8351f6db1fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 14:37:40 +0100 Subject: [PATCH 316/365] improve save and load --- pygsp/tests/test_graphs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 46b3fdc4..b59a90bd 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -742,7 +742,7 @@ def test_save_load(self): all_files = [] for fmt in nx_gt: all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph_tool') + graph.save("graph_gt.{}".format(fmt), backend='graph-tool') graph.save("graph_nx.{}".format(fmt), backend='networkx') graph.save("graph_nx.{}".format('gexf'), backend='networkx') all_files += ["graph_nx.{}".format('gexf')] @@ -754,7 +754,7 @@ def test_save_load(self): np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph_tool') + graph_loaded_gt = graphs.Graph.load(filename, backend='graph-tool') np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) @@ -781,9 +781,9 @@ def test_import_errors(self): self.assertRaises(ImportError, graph.to_graphtool) self.assertRaises(ImportError, graphs.Graph.from_graphtool, None) self.assertRaises(ImportError, graph.save, filename, - backend='graph_tool') + backend='graph-tool') self.assertRaises(ImportError, graphs.Graph.load, filename, - backend='graph_tool') + backend='graph-tool') graph.save(filename) graphs.Graph.load(filename) with patch.dict(sys.modules, {'networkx': None, 'graph_tool': None}): From e4800578c8de19d4d7cd12fbd952adc9c572eb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 05:40:03 +0100 Subject: [PATCH 317/365] use RandomState instead of seeding whole numpy --- pygsp/tests/test_graphs.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b59a90bd..b799aa1c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -666,9 +666,9 @@ def key(edge): return str(edge.source()) + ":" + str(edge.target()) def test_networkx_signal_export(self): graph = graphs.BarabasiAlbert(N=100, seed=42) - np.random.seed(42) - signal1 = np.random.random(graph.N) - signal2 = np.random.random(graph.N) + rs = np.random.RandomState(42) + signal1 = rs.normal(size=graph.N) + signal2 = rs.normal(size=graph.N) graph.set_signal(signal1, "signal1") graph.set_signal(signal2, "signal2") graph_nx = graph.to_networkx() @@ -678,9 +678,9 @@ def test_networkx_signal_export(self): def test_graphtool_signal_export(self): g = graphs.Logo() - np.random.seed(42) - s = np.random.random(g.N) - s2 = np.random.random(g.N) + rs = np.random.RandomState(42) + s = rs.normal(size=g.N) + s2 = rs.normal(size=g.N) g.set_signal(s, "signal1") g.set_signal(s2, "signal2") g_gt = g.to_graphtool() @@ -733,8 +733,7 @@ def test_networkx_signal_import(self): def test_save_load(self): graph = graphs.Sensor(seed=42) - np.random.seed(42) - signal = np.random.random(graph.N) + signal = np.random.RandomState(42).uniform(size=graph.N) graph.set_signal(signal, "signal") # save From 60bbb5225ba33b17541261559b299b9758cee99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Mar 2019 05:40:36 +0100 Subject: [PATCH 318/365] better test of save and load --- pygsp/tests/test_graphs.py | 57 +++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index b799aa1c..28f63659 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -732,35 +732,34 @@ def test_networkx_signal_import(self): @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): - graph = graphs.Sensor(seed=42) - signal = np.random.RandomState(42).uniform(size=graph.N) - graph.set_signal(signal, "signal") - - # save - nx_gt = ['gml', 'graphml'] - all_files = [] - for fmt in nx_gt: - all_files += ["graph_gt.{}".format(fmt), "graph_nx.{}".format(fmt)] - graph.save("graph_gt.{}".format(fmt), backend='graph-tool') - graph.save("graph_nx.{}".format(fmt), backend='networkx') - graph.save("graph_nx.{}".format('gexf'), backend='networkx') - all_files += ["graph_nx.{}".format('gexf')] - - # load - for filename in all_files: - if not "_gt" in filename: - graph_loaded_nx = graphs.Graph.load(filename, backend='networkx') - np.testing.assert_array_equal(graph.W.todense(), graph_loaded_nx.W.todense()) - np.testing.assert_array_equal(signal, graph_loaded_nx.signals['signal']) - if not ".gexf" in filename: - graph_loaded_gt = graphs.Graph.load(filename, backend='graph-tool') - np.testing.assert_allclose(graph.W.todense(), graph_loaded_gt.W.todense(), atol=0.000001) - np.testing.assert_allclose(signal, graph_loaded_gt.signals['signal'], atol=0.000001) - - # clean - for filename in all_files: - os.remove(filename) - + G1 = graphs.Sensor(seed=42) + W = G1.W.toarray() + sig = np.random.RandomState(42).normal(size=G1.N) + G1.set_signal(sig, 's') + + for fmt in ['graphml', 'gml', 'gexf']: + for backend in ['networkx', 'graph-tool']: + + if fmt == 'gexf' and backend == 'graph-tool': + self.assertRaises(ValueError, G1.save, 'g', fmt, backend) + self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt, + backend) + continue + + atol = 1e-5 if fmt == 'gml' and backend == 'graph-tool' else 0 + + for filename, fmt in [('graph.' + fmt, None), ('graph', fmt)]: + G1.save(filename, fmt, backend) + G2 = graphs.Graph.load(filename, fmt, backend) + np.testing.assert_allclose(G2.W.toarray(), W, atol=atol) + np.testing.assert_allclose(G2.signals['s'], sig, atol=atol) + os.remove(filename) + + self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt='unk') + self.assertRaises(ValueError, graphs.Graph.load, 'g', backend='unk') + self.assertRaises(ValueError, G1.save, 'g', fmt='unk') + self.assertRaises(ValueError, G1.save, 'g', backend='unk') + os.remove('g') @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') def test_import_errors(self): From f5561f70c620391cabb093386ab9500e57d4fcb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Mar 2019 04:35:24 +0100 Subject: [PATCH 319/365] clean & improve {to,from}_{networkx,graphtool} --- pygsp/tests/test_utils.py | 6 ------ pygsp/utils.py | 41 --------------------------------------- 2 files changed, 47 deletions(-) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index a7298319..af23f4b8 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -32,11 +32,5 @@ def test_symmetrize(self): np.testing.assert_equal(W1.toarray(), W2) self.assertRaises(ValueError, utils.symmetrize, W, 'sum') - def test_convert_dtype(self): - signal = np.zeros(10, dtype=np.int16) - self.assertEqual(utils.convert_dtype(signal.dtype), 'int16_t') - signal = np.zeros(10, dtype=np.float128) - self.assertEqual(utils.convert_dtype(signal.dtype), None) - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/utils.py b/pygsp/utils.py index 70fbfcc9..94474eb1 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -364,44 +364,3 @@ def import_functions(names, src, dst): for name in names: module = importlib.import_module('pygsp.' + src) setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) - - -def convert_dtype(dtype): - r"""Convert from numpy dtype to graph tool types. - - The supported numpy types are: ``bool_``, ``int_``, ``int16``, ``int32``, - ``int64``, ``float_``, ``float16``, ``float32``, ``float64``. - - See the `graph-tool documentation - `_ - for details. - - Parameters - ---------- - dtype : :class:`numpy.dtype` - Numpy data type. - - Returns - ------- - type : string - A string representing the type, ready to be used by graph-tool. - - """ - translation = { - np.bool_: 'bool', - np.int_: 'int', - np.int16: 'int16_t', - np.int32: 'int32_t', - np.int64: 'int64_t', - np.float_: 'long double', - np.float16: 'double', - np.float32: 'double', - np.float64: 'long double' - } - - try: - graph_tool_type = translation[dtype.type] - except KeyError: - graph_tool_type = None - - return graph_tool_type From df37c81f8356fdd27411ffa9a46704912330df7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 12:34:49 +0100 Subject: [PATCH 320/365] test import/export without edge weights --- pygsp/tests/test_graphs.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 28f63659..1c2aa67c 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -729,6 +729,42 @@ def test_networkx_signal_import(self): self.assertEqual(g.signals["signal1"][i], nx.get_node_attributes(g_nx, "signal1")[node]) + def test_no_weights(self): + + adjacency = np.array([[0, 1, 0], [1, 0, 1], [0, 1, 0]]) + + # NetworkX no weights. + graph_nx = nx.Graph() + graph_nx.add_edge(0, 1) + graph_nx.add_edge(1, 2) + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # NetworkX non-existent weight name. + graph_nx.edges[(0, 1)]['weight'] = 2 + graph_nx.edges[(1, 2)]['weight'] = 2 + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) + graph_pg = graphs.Graph.from_networkx(graph_nx, weight='unknown') + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # Graph-tool no weights. + graph_gt = gt.Graph(directed=False) + graph_gt.add_edge(0, 1) + graph_gt.add_edge(1, 2) + graph_pg = graphs.Graph.from_graphtool(graph_gt) + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + + # Graph-tool non-existent weight name. + prop = graph_gt.new_edge_property("double") + prop[(0, 1)] = 2 + prop[(1, 2)] = 2 + graph_gt.edge_properties["weight"] = prop + graph_pg = graphs.Graph.from_graphtool(graph_gt) + np.testing.assert_allclose(graph_pg.W.toarray(), 2*adjacency) + graph_pg = graphs.Graph.from_graphtool(graph_gt, weight='unknown') + np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From abc69cf398825de2a81b45620c43c23dbd598d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 13:24:14 +0100 Subject: [PATCH 321/365] contributing: extras requirement and partial test suite --- CONTRIBUTING.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ddfd8afb..edefd8f7 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -24,6 +24,11 @@ manually as it cannot be installed by pip. ======= >>>>>>> merge all the extra requirements in a single dev requirement +The ``dev`` "extras requirement" ensures that dependencies required for +development (to run the test suite and build the documentation) are installed. +Only `graph-tool `_ will be missing: install it +manually as it cannot be installed by pip. + You can improve or add functionality in the ``pygsp`` folder, along with corresponding unit tests in ``pygsp/tests/test_*.py`` (with reasonable coverage) and documentation in ``doc/reference/*.rst``. If you have a nice From cbcb5df5ef32958a5209465ce8a3633041489a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 14:31:55 +0100 Subject: [PATCH 322/365] break and join signals on I/O --- pygsp/tests/test_graphs.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 1c2aa67c..6b7c252f 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -765,6 +765,25 @@ def test_no_weights(self): graph_pg = graphs.Graph.from_graphtool(graph_gt, weight='unknown') np.testing.assert_allclose(graph_pg.W.toarray(), adjacency) + def test_break_join_signals(self): + """Multi-dim signals are broken on export and joined on import.""" + graph_1 = graphs.Sensor(20, seed=42) + graph_1.set_signal(graph_1.coords, 'coords') + # networkx + graph_2 = graph_1.to_networkx() + graph_2 = graphs.Graph.from_networkx(graph_2) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + # graph-tool + graph_2 = graph_1.to_graphtool() + graph_2 = graphs.Graph.from_graphtool(graph_2) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + # save and load + filename = 'graph.graphml' + graph_1.save(filename) + graph_2 = graphs.Graph.load(filename) + np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + os.remove(filename) + @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From 8e24e69c1c243ee3eed5f5c68a6b855e81d1fa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 14:55:41 +0100 Subject: [PATCH 323/365] sorted dict fix for python < 3.6 --- pygsp/tests/test_graphs.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6b7c252f..638b60f6 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -767,22 +767,23 @@ def test_no_weights(self): def test_break_join_signals(self): """Multi-dim signals are broken on export and joined on import.""" - graph_1 = graphs.Sensor(20, seed=42) - graph_1.set_signal(graph_1.coords, 'coords') + graph1 = graphs.Sensor(20, seed=42) + graph1.set_signal(graph1.coords, 'coords') # networkx - graph_2 = graph_1.to_networkx() - graph_2 = graphs.Graph.from_networkx(graph_2) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) + graph2 = graph1.to_networkx() + graph2 = graphs.Graph.from_networkx(graph2) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) # graph-tool - graph_2 = graph_1.to_graphtool() - graph_2 = graphs.Graph.from_graphtool(graph_2) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) - # save and load - filename = 'graph.graphml' - graph_1.save(filename) - graph_2 = graphs.Graph.load(filename) - np.testing.assert_allclose(graph_2.signals['coords'], graph_1.coords) - os.remove(filename) + graph2 = graph1.to_graphtool() + graph2 = graphs.Graph.from_graphtool(graph2) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) + # save and load (need ordered dicts) + if sys.version_info >= (3, 6): + filename = 'graph.graphml' + graph1.save(filename) + graph2 = graphs.Graph.load(filename) + np.testing.assert_allclose(graph2.signals['coords'], graph1.coords) + os.remove(filename) @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): From 5d3a3a836d98b9ab071412ba9254df032231582b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 15:20:41 +0100 Subject: [PATCH 324/365] update I/O doc --- doc/history.rst | 5 + pygsp/graphs/__init__.py | 14 + pygsp/graphs/graph.py | 561 +++++++++++++++++++++++++++++++++++++ pygsp/tests/test_graphs.py | 6 +- 4 files changed, 585 insertions(+), 1 deletion(-) diff --git a/doc/history.rst b/doc/history.rst index f0522de7..2e002bb4 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -25,10 +25,15 @@ History * A new learning module with three functions to solve standard semi-supervised classification and regression problems. <<<<<<< HEAD +<<<<<<< HEAD * A much improved, fixed, documented, and tested NNGraph. The user can now select the backend and similarity kernel. The radius can be estimated and features standardized. (PR #43) ======= +======= +* Import and export graphs and their signals to NetworkX and graph-tool. +* Save and load graphs and theirs signals to / from GraphML, GML, and GEXF. +>>>>>>> update I/O doc * Merged all the extra requirements in a single dev requirement. >>>>>>> merge all the extra requirements in a single dev requirement diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 4f406c15..8e1b327b 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -90,6 +90,20 @@ Import and export (I/O) ----------------------- +We provide import and export facility to two well-known Python packages for +network analysis: NetworkX_ and graph-tool_. +Those packages and the PyGSP are fundamentally different in their goals (graph +analysis versus graph signal analysis) and graph representations (if in the +PyGSP everything is an ndarray, in NetworkX everything is a dictionary). +Those tools are complementary and good interoperability is necessary to exploit +the strengths of each tool. +We ourselves leverage NetworkX and graph-tool to save and load graphs. + +Note: to tie a signal with the graph, such that they are exported together, +attach it first with :meth:`Graph.set_signal`. + +.. _NetworkX: https://networkx.github.io +.. _graph-tool: https://graph-tool.skewed.de .. autosummary:: diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 723adf0a..418d49ed 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -208,6 +208,567 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) + def _break_signals(self): + r"""Break N-dimensional signals into N 1D signals.""" + for name in list(self.signals.keys()): + if self.signals[name].ndim == 2: + for i, signal_1d in enumerate(self.signals[name].T): + self.signals[name + '_' + str(i)] = signal_1d + del self.signals[name] + + def _join_signals(self): + r"""Join N 1D signals into one N-dimensional signal.""" + joined = dict() + for name in self.signals: + name_base = name.rsplit('_', 1)[0] + names = joined.get(name_base, list()) + names.append(name) + joined[name_base] = names + for name_base, names in joined.items(): + if len(names) > 1: + names = sorted(names) # ensure dim ordering (_0, _1, etc.) + signal_nd = np.stack([self.signals[n] for n in names], axis=1) + self.signals[name_base] = signal_nd + for name in names: + del self.signals[name] + + def to_networkx(self): + r"""Export the graph to NetworkX. + + Edge weights are stored as an edge attribute, + under the name "weight". + + Signals are stored as node attributes, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Returns + ------- + graph : :class:`networkx.Graph` + A NetworkX graph object. + + See also + -------- + to_graphtool : export to graph-tool + save : save to a file + + Examples + -------- + >>> import networkx as nx + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Path(4, directed=True) + >>> graph.set_signal(np.full(4, 2.3), 'signal') + >>> graph = graph.to_networkx() + >>> print(nx.info(graph)) + Name: Path + Type: DiGraph + Number of nodes: 4 + Number of edges: 3 + Average in degree: 0.7500 + Average out degree: 0.7500 + >>> nx.is_directed(graph) + True + >>> graph.nodes() + NodeView((0, 1, 2, 3)) + >>> graph.edges() + OutEdgeView([(0, 1), (1, 2), (2, 3)]) + >>> graph.nodes()[2] + {'signal': 2.3} + >>> graph.edges()[(0, 1)] + {'weight': 1.0} + >>> nx.draw(graph, with_labels=True) + + Another common goal is to use NetworkX to compute some properties to be + be imported back in the PyGSP as signals. + + >>> import networkx as nx + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Sensor(100, seed=42) + >>> graph.set_signal(graph.coords, 'coords') + >>> graph = graph.to_networkx() + >>> betweenness = nx.betweenness_centrality(graph, weight='weight') + >>> nx.set_node_attributes(graph, betweenness, 'betweenness') + >>> graph = graphs.Graph.from_networkx(graph) + >>> graph.compute_fourier_basis() + >>> graph.set_coordinates(graph.signals['coords']) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) + >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) + + """ + nx = _import_networkx() + + def convert(number): + # NetworkX accepts arbitrary python objects as attributes, but: + # * the GEXF writer does not accept any NumPy types (on signals), + # * the GraphML writer does not accept NumPy ints. + if issubclass(number.dtype.type, (np.integer, np.bool_)): + return int(number) + else: + return float(number) + + def edges(): + for source, target, weight in zip(*self.get_edge_list()): + yield source, target, {'weight': convert(weight)} + + def nodes(): + for vertex in range(self.n_vertices): + signals = {name: convert(signal[vertex]) + for name, signal in self.signals.items()} + yield vertex, signals + + self._break_signals() + graph = nx.DiGraph() if self.is_directed() else nx.Graph() + graph.add_nodes_from(nodes()) + graph.add_edges_from(edges()) + graph.name = self.__class__.__name__ + return graph + + def to_graphtool(self): + r"""Export the graph to graph-tool. + + Edge weights are stored as an edge property map, + under the name "weight". + + Signals are stored as vertex property maps, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Returns + ------- + graph : :class:`graph_tool.Graph` + A graph-tool graph object. + + See also + -------- + to_networkx : export to NetworkX + save : save to a file + + Examples + -------- + >>> import graph_tool as gt + >>> import graph_tool.draw + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Path(4, directed=True) + >>> graph.set_signal(np.full(4, 2.3), 'signal') + >>> graph = graph.to_graphtool() + >>> graph.is_directed() + True + >>> graph.vertex_properties['signal'][2] + 2.3 + >>> graph.edge_properties['weight'][(0, 1)] + 1.0 + >>> # gt.draw.graph_draw(graph, vertex_text=graph.vertex_index) + + Another common goal is to use graph-tool to compute some properties to + be imported back in the PyGSP as signals. + + >>> import graph_tool as gt + >>> import graph_tool.centrality + >>> from matplotlib import pyplot as plt + >>> graph = graphs.Sensor(100, seed=42) + >>> graph.set_signal(graph.coords, 'coords') + >>> graph = graph.to_graphtool() + >>> vprop, eprop = gt.centrality.betweenness( + ... graph, weight=graph.edge_properties['weight']) + >>> graph.vertex_properties['betweenness'] = vprop + >>> graph = graphs.Graph.from_graphtool(graph) + >>> graph.compute_fourier_basis() + >>> graph.set_coordinates(graph.signals['coords']) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) + >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) + + """ + + # See gt.value_types() for the list of accepted types. + # See the definition of _type_alias() for a list of aliases. + # Mapping from https://docs.scipy.org/doc/numpy/user/basics.types.html. + convert = { + np.bool_: 'bool', + np.int8: 'int8_t', + np.int16: 'int16_t', + np.int32: 'int32_t', + np.int64: 'int64_t', + np.short: 'short', + np.intc: 'int', + np.uintc: 'unsigned int', + np.long: 'long', + np.longlong: 'long long', + np.uint: 'unsigned long', + np.single: 'float', + np.double: 'double', + np.longdouble: 'long double', + } + + gt = _import_graphtool() + graph = gt.Graph(directed=self.is_directed()) + + sources, targets, weights = self.get_edge_list() + graph.add_edge_list(np.asarray((sources, targets)).T) + try: + dtype = convert[weights.dtype.type] + except KeyError: + raise ValueError("Type {} of the edge weights is not supported." + .format(weights.dtype)) + prop = graph.new_edge_property(dtype) + prop.get_array()[:] = weights + graph.edge_properties['weight'] = prop + + self._break_signals() + for name, signal in self.signals.items(): + try: + dtype = convert[signal.dtype.type] + except KeyError: + raise ValueError("Type {} of signal {} is not supported." + .format(signal.dtype, name)) + prop = graph.new_vertex_property(dtype) + prop.get_array()[:] = signal + graph.vertex_properties[name] = prop + + return graph + + @classmethod + def from_networkx(cls, graph, weight='weight'): + r"""Import a graph from NetworkX. + + Edge weights are retrieved as an edge attribute, + under the name specified by the ``weight`` parameter. + + Signals are retrieved from node attributes, + and stored in the :attr:`signals` dictionary under the attribute name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + graph : :class:`networkx.Graph` + A NetworkX graph object. + weight : string or None, optional + The edge attribute that holds the numerical values used as the edge + weights. All edge weights are set to 1 if None, or not found. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + A PyGSP graph object. + + Notes + ----- + + The nodes are ordered according to :meth:`networkx.Graph.nodes`. + + In NetworkX, node attributes need not be set for every node. + If a node attribute is not set for a node, a NaN is assigned to the + corresponding signal for that node. + + If the graph is a :class:`networkx.MultiGraph`, multiedges are + aggregated by summation. + + See also + -------- + from_graphtool : import from graph-tool + load : load from a file + + Examples + -------- + >>> import networkx as nx + >>> graph = nx.Graph() + >>> graph.add_edge(1, 2, weight=0.2) + >>> graph.add_edge(2, 3, weight=0.9) + >>> graph.add_node(4, sig=3.1416) + >>> graph.nodes() + NodeView((1, 2, 3, 4)) + >>> graph = graphs.Graph.from_networkx(graph) + >>> graph.W.toarray() + array([[0. , 0.2, 0. , 0. ], + [0.2, 0. , 0.9, 0. ], + [0. , 0.9, 0. , 0. ], + [0. , 0. , 0. , 0. ]]) + >>> graph.signals + {'sig': array([ nan, nan, nan, 3.1416])} + + """ + nx = _import_networkx() + + adjacency = nx.to_scipy_sparse_matrix(graph, weight=weight) + graph_pg = Graph(adjacency) + + for i, node in enumerate(graph.nodes()): + for name in graph.nodes[node].keys(): + try: + signal = graph_pg.signals[name] + except KeyError: + signal = np.full(graph_pg.n_vertices, np.nan) + graph_pg.set_signal(signal, name) + try: + signal[i] = graph.nodes[node][name] + except KeyError: + pass # attribute not set for node + + graph_pg._join_signals() + return graph_pg + + @classmethod + def from_graphtool(cls, graph, weight='weight'): + r"""Import a graph from graph-tool. + + Edge weights are retrieved as an edge property, + under the name specified by the ``weight`` parameter. + + Signals are retrieved from node properties, + and stored in the :attr:`signals` dictionary under the property name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + graph : :class:`graph_tool.Graph` + A graph-tool graph object. + weight : string + The edge property that holds the numerical values used as the edge + weights. All edge weights are set to 1 if None, or not found. + + Returns + ------- + graph : :class:`~pygsp.graphs.Graph` + A PyGSP graph object. + + Notes + ----- + + If the graph has multiple edge connecting the same two nodes, a sum + over the edges is taken to merge them. + + See also + -------- + from_networkx : import from NetworkX + load : load from a file + + Examples + -------- + >>> import graph_tool as gt + >>> graph = gt.Graph(directed=False) + >>> e1 = graph.add_edge(0, 1) + >>> e2 = graph.add_edge(1, 2) + >>> v = graph.add_vertex() + >>> eprop = graph.new_edge_property("double") + >>> eprop[e1] = 0.2 + >>> eprop[(1, 2)] = 0.9 + >>> graph.edge_properties["weight"] = eprop + >>> vprop = graph.new_vertex_property("double", val=np.nan) + >>> vprop[3] = 3.1416 + >>> graph.vertex_properties["sig"] = vprop + >>> graph = graphs.Graph.from_graphtool(graph) + >>> graph.W.toarray() + array([[0. , 0.2, 0. , 0. ], + [0.2, 0. , 0.9, 0. ], + [0. , 0.9, 0. , 0. ], + [0. , 0. , 0. , 0. ]]) + >>> graph.signals + {'sig': PropertyArray([ nan, nan, nan, 3.1416])} + + """ + gt = _import_graphtool() + import graph_tool.spectral + + weight = graph.edge_properties.get(weight, None) + adjacency = gt.spectral.adjacency(graph, weight=weight) + graph_pg = Graph(adjacency.T) + + for name, signal in graph.vertex_properties.items(): + graph_pg.set_signal(signal.get_array(), name) + + graph_pg._join_signals() + return graph_pg + + @classmethod + def load(cls, path, fmt=None, backend=None): + r"""Load a graph from a file. + + Edge weights are retrieved as an edge attribute named "weight". + + Signals are retrieved from node attributes, + and stored in the :attr:`signals` dictionary under the attribute name. + `N`-dimensional signals that were broken during export are joined. + + Parameters + ---------- + path : string + Path to the file from which to load the graph. + fmt : {'graphml', 'gml', 'gexf', None}, optional + Format in which the graph is saved. + Guessed from the filename extension if None. + backend : {'networkx', 'graph-tool', None}, optional + Library used to load the graph. Automatically chosen if None. + + Returns + ------- + graph : :class:`Graph` + The loaded graph. + + See also + -------- + save : save a graph to a file + from_networkx : load with NetworkX then import in the PyGSP + from_graphtool : load with graph-tool then import in the PyGSP + + Notes + ----- + + A lossless round-trip is only guaranteed if the graph (and its signals) + is saved and loaded with the same backend. + + Loading from other formats is possible by loading in NetworkX or + graph-tool, and importing to the PyGSP. + The proposed formats are however tested for faithful round-trips. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') + >>> graph = graphs.Graph.load('logo.graphml') + >>> import os + >>> os.remove('logo.graphml') + + """ + + if fmt is None: + fmt = os.path.splitext(path)[1][1:] + if fmt not in ['graphml', 'gml', 'gexf']: + raise ValueError('Unsupported format {}.'.format(fmt)) + + def load_networkx(path, fmt): + nx = _import_networkx() + load = getattr(nx, 'read_' + fmt) + graph = load(path) + return cls.from_networkx(graph) + + def load_graphtool(path, fmt): + gt = _import_graphtool() + graph = gt.load_graph(path, fmt=fmt) + return cls.from_graphtool(graph) + + if backend == 'networkx': + return load_networkx(path, fmt) + elif backend == 'graph-tool': + return load_graphtool(path, fmt) + elif backend is None: + try: + return load_networkx(path, fmt) + except ImportError: + try: + return load_graphtool(path, fmt) + except ImportError: + raise ImportError('Cannot import networkx nor graph-tool.') + else: + raise ValueError('Unknown backend {}.'.format(backend)) + + def save(self, path, fmt=None, backend=None): + r"""Save the graph to a file. + + Edge weights are stored as an edge attribute, + under the name "weight". + + Signals are stored as node attributes, + under their name in the :attr:`signals` dictionary. + `N`-dimensional signals are broken into `N` 1-dimensional signals. + They will eventually be joined back together on import. + + Parameters + ---------- + path : string + Path to the file where the graph is to be saved. + fmt : {'graphml', 'gml', 'gexf', None}, optional + Format in which to save the graph. + Guessed from the filename extension if None. + backend : {'networkx', 'graph-tool', None}, optional + Library used to load the graph. Automatically chosen if None. + + See also + -------- + load : load a graph from a file + to_networkx : export as a NetworkX graph, and save with NetworkX + to_graphtool : export as a graph-tool graph, and save with graph-tool + + Notes + ----- + + A lossless round-trip is only guaranteed if the graph (and its signals) + is saved and loaded with the same backend. + + Saving in other formats is possible by exporting to NetworkX or + graph-tool, and using their respective saving functionality. + The proposed formats are however tested for faithful round-trips. + + Edge weights and signal values are rounded at the sixth decimal when + saving in ``fmt='gml'`` with ``backend='graph-tool'``. + + Examples + -------- + >>> graph = graphs.Logo() + >>> graph.save('logo.graphml') + >>> graph = graphs.Graph.load('logo.graphml') + >>> import os + >>> os.remove('logo.graphml') + + """ + + if fmt is None: + fmt = os.path.splitext(path)[1][1:] + if fmt not in ['graphml', 'gml', 'gexf']: + raise ValueError('Unsupported format {}.'.format(fmt)) + + def save_networkx(graph, path, fmt): + nx = _import_networkx() + graph = graph.to_networkx() + save = getattr(nx, 'write_' + fmt) + save(graph, path) + + def save_graphtool(graph, path, fmt): + graph = graph.to_graphtool() + graph.save(path, fmt=fmt) + + if backend == 'networkx': + save_networkx(self, path, fmt) + elif backend == 'graph-tool': + save_graphtool(self, path, fmt) + elif backend is None: + try: + save_networkx(self, path, fmt) + except ImportError: + try: + save_graphtool(self, path, fmt) + except ImportError: + raise ImportError('Cannot import networkx nor graph-tool.') + else: + raise ValueError('Unknown backend {}.'.format(backend)) + + def set_signal(self, signal, name): + r"""Attach a signal to the graph. + + Attached signals can be accessed (and modified or deleted) through the + :attr:`signals` dictionary. + + Parameters + ---------- + signal : array_like + A sequence that assigns a value to each vertex. + The value of the signal at vertex `i` is ``signal[i]``. + name : String + Name of the signal used as a key in the :attr:`signals` dictionary. + + Examples + -------- + >>> graph = graphs.Sensor(10) + >>> signal = np.arange(graph.n_vertices) + >>> graph.set_signal(signal, 'mysignal') + >>> graph.signals + {'mysignal': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])} + + """ + signal = self._check_signal(signal) + self.signals[name] = signal def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 638b60f6..fc022509 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -622,7 +622,7 @@ def test_graphtool_export_import(self): def test_graphtool_multiedge_import(self): # Manualy create a graph with multiple edges g_gt = gt.Graph() - g_gt.add_vertex(10) + g_gt.add_vertex(n=10) # connect edge (3,6) three times for i in range(3): g_gt.add_edge(g_gt.vertex(3), g_gt.vertex(6)) @@ -788,6 +788,10 @@ def test_break_join_signals(self): @unittest.skipIf(sys.version_info < (3, 6), 'need ordered dicts') def test_save_load(self): + # TODO: test with multiple graphs and signals + # * dtypes (float, int, bool) of adjacency and signals + # * empty graph / isolated nodes + G1 = graphs.Sensor(seed=42) W = G1.W.toarray() sig = np.random.RandomState(42).normal(size=G1.N) From 99a934cf0a87bd8791fb6ed2e016b23ac7875dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 15:46:28 +0100 Subject: [PATCH 325/365] move I/O and layout in their own modules --- pygsp/graphs/_io.py | 50 +-- pygsp/graphs/_layout.py | 4 +- pygsp/graphs/graph.py | 712 ++++------------------------------------ 3 files changed, 68 insertions(+), 698 deletions(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 3ac774c1..9748ea3b 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -66,7 +66,7 @@ def to_networkx(self): graph : :class:`networkx.Graph` A NetworkX graph object. - See Also + See also -------- to_graphtool : export to graph-tool save : save to a file @@ -128,7 +128,7 @@ def convert(number): def edges(): for source, target, weight in zip(*self.get_edge_list()): - yield int(source), int(target), {'weight': convert(weight)} + yield source, target, {'weight': convert(weight)} def nodes(): for vertex in range(self.n_vertices): @@ -159,7 +159,7 @@ def to_graphtool(self): graph : :class:`graph_tool.Graph` A graph-tool graph object. - See Also + See also -------- to_networkx : export to NetworkX save : save to a file @@ -229,8 +229,8 @@ def to_graphtool(self): try: dtype = convert[weights.dtype.type] except KeyError: - raise TypeError("Type {} of the edge weights is not supported." - .format(weights.dtype)) + raise ValueError("Type {} of the edge weights is not supported." + .format(weights.dtype)) prop = graph.new_edge_property(dtype) prop.get_array()[:] = weights graph.edge_properties['weight'] = prop @@ -240,8 +240,8 @@ def to_graphtool(self): try: dtype = convert[signal.dtype.type] except KeyError: - raise TypeError("Type {} of signal {} is not supported." - .format(signal.dtype, name)) + raise ValueError("Type {} of signal {} is not supported." + .format(signal.dtype, name)) prop = graph.new_vertex_property(dtype) prop.get_array()[:] = signal graph.vertex_properties[name] = prop @@ -284,7 +284,7 @@ def from_networkx(cls, graph, weight='weight'): If the graph is a :class:`networkx.MultiGraph`, multiedges are aggregated by summation. - See Also + See also -------- from_graphtool : import from graph-tool load : load from a file @@ -359,7 +359,7 @@ def from_graphtool(cls, graph, weight='weight'): If the graph has multiple edge connecting the same two nodes, a sum over the edges is taken to merge them. - See Also + See also -------- from_networkx : import from NetworkX load : load from a file @@ -427,7 +427,7 @@ def load(cls, path, fmt=None, backend=None): graph : :class:`Graph` The loaded graph. - See Also + See also -------- save : save a graph to a file from_networkx : load with NetworkX then import in the PyGSP @@ -495,34 +495,6 @@ def save(self, path, fmt=None, backend=None): `N`-dimensional signals are broken into `N` 1-dimensional signals. They will eventually be joined back together on import. - Supported formats are: - - * GraphML_, a comprehensive XML format. - `Wikipedia `_. - Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, - Cytoscape_, SocNetV_. - * GML_ (Graph Modelling Language), a simple non-XML format. - `Wikipedia `_. - Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, - Cytoscape_, SocNetV_, Tulip_. - * GEXF_ (Graph Exchange XML Format), Gephi's XML format. - Supported by NetworkX_, NetworKit_, Gephi_, Tulip_, ngraph_. - - If unsure, we recommend GraphML_. - - .. _GraphML: http://graphml.graphdrawing.org - .. _GML: http://www.infosun.fim.uni-passau.de/Graphlet/GML/gml-tr.html - .. _GEXF: https://gephi.org/gexf/format - .. _NetworkX: https://networkx.github.io - .. _graph-tool: https://graph-tool.skewed.de - .. _NetworKit: https://networkit.github.io - .. _igraph: https://igraph.org - .. _ngraph: https://github.com/anvaka/ngraph - .. _Gephi: https://gephi.org - .. _Cytoscape: https://cytoscape.org - .. _SocNetV: https://socnetv.org - .. _Tulip: http://tulip.labri.fr - Parameters ---------- path : string @@ -533,7 +505,7 @@ def save(self, path, fmt=None, backend=None): backend : {'networkx', 'graph-tool', None}, optional Library used to load the graph. Automatically chosen if None. - See Also + See also -------- load : load a graph from a file to_networkx : export as a NetworkX graph, and save with NetworkX diff --git a/pygsp/graphs/_layout.py b/pygsp/graphs/_layout.py index 31127fd1..b20fcf89 100644 --- a/pygsp/graphs/_layout.py +++ b/pygsp/graphs/_layout.py @@ -92,10 +92,10 @@ def set_coordinates(self, kind='spring', **kwargs): self.coords[i] = self.info['com_coords'][comm_idx] + \ comm_rad * self.coords[i] elif kind == 'laplacian_eigenmap2D': - self.compute_fourier_basis(n_eigenvectors=3) + self.compute_fourier_basis(n_eigenvectors=2) self.coords = self.U[:, 1:3] elif kind == 'laplacian_eigenmap3D': - self.compute_fourier_basis(n_eigenvectors=4) + self.compute_fourier_basis(n_eigenvectors=3) self.coords = self.U[:, 1:4] else: raise ValueError('Unexpected argument kind={}.'.format(kind)) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 418d49ed..17dad05c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -14,26 +14,7 @@ from ._layout import LayoutMixIn -def _import_networkx(): - try: - import networkx as nx - except Exception as e: - raise ImportError('Cannot import networkx. Use graph-tool or try to ' - 'install it with pip (or conda) install networkx. ' - 'Original exception: {}'.format(e)) - return nx - - -def _import_graphtool(): - try: - import graph_tool as gt - except Exception as e: - raise ImportError('Cannot import graph-tool. Use networkx or try to ' - 'install it. Original exception: {}'.format(e)) - return gt - - -class Graph(fourier.GraphFourier, difference.GraphDifference): +class Graph(FourierMixIn, DifferenceMixIn, IOMixIn, LayoutMixIn): r"""Base graph class. * Instantiate it to construct a graph from a (weighted) adjacency matrix. @@ -208,567 +189,86 @@ def __repr__(self, limit=None): s += '{}={}, '.format(key, value) return '{}({})'.format(self.__class__.__name__, s[:-2]) - def _break_signals(self): - r"""Break N-dimensional signals into N 1D signals.""" - for name in list(self.signals.keys()): - if self.signals[name].ndim == 2: - for i, signal_1d in enumerate(self.signals[name].T): - self.signals[name + '_' + str(i)] = signal_1d - del self.signals[name] - - def _join_signals(self): - r"""Join N 1D signals into one N-dimensional signal.""" - joined = dict() - for name in self.signals: - name_base = name.rsplit('_', 1)[0] - names = joined.get(name_base, list()) - names.append(name) - joined[name_base] = names - for name_base, names in joined.items(): - if len(names) > 1: - names = sorted(names) # ensure dim ordering (_0, _1, etc.) - signal_nd = np.stack([self.signals[n] for n in names], axis=1) - self.signals[name_base] = signal_nd - for name in names: - del self.signals[name] - - def to_networkx(self): - r"""Export the graph to NetworkX. - - Edge weights are stored as an edge attribute, - under the name "weight". - - Signals are stored as node attributes, - under their name in the :attr:`signals` dictionary. - `N`-dimensional signals are broken into `N` 1-dimensional signals. - They will eventually be joined back together on import. - - Returns - ------- - graph : :class:`networkx.Graph` - A NetworkX graph object. - - See also - -------- - to_graphtool : export to graph-tool - save : save to a file - - Examples - -------- - >>> import networkx as nx - >>> from matplotlib import pyplot as plt - >>> graph = graphs.Path(4, directed=True) - >>> graph.set_signal(np.full(4, 2.3), 'signal') - >>> graph = graph.to_networkx() - >>> print(nx.info(graph)) - Name: Path - Type: DiGraph - Number of nodes: 4 - Number of edges: 3 - Average in degree: 0.7500 - Average out degree: 0.7500 - >>> nx.is_directed(graph) - True - >>> graph.nodes() - NodeView((0, 1, 2, 3)) - >>> graph.edges() - OutEdgeView([(0, 1), (1, 2), (2, 3)]) - >>> graph.nodes()[2] - {'signal': 2.3} - >>> graph.edges()[(0, 1)] - {'weight': 1.0} - >>> nx.draw(graph, with_labels=True) - - Another common goal is to use NetworkX to compute some properties to be - be imported back in the PyGSP as signals. - - >>> import networkx as nx - >>> from matplotlib import pyplot as plt - >>> graph = graphs.Sensor(100, seed=42) - >>> graph.set_signal(graph.coords, 'coords') - >>> graph = graph.to_networkx() - >>> betweenness = nx.betweenness_centrality(graph, weight='weight') - >>> nx.set_node_attributes(graph, betweenness, 'betweenness') - >>> graph = graphs.Graph.from_networkx(graph) - >>> graph.compute_fourier_basis() - >>> graph.set_coordinates(graph.signals['coords']) - >>> fig, axes = plt.subplots(1, 2) - >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) - >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) - - """ - nx = _import_networkx() - - def convert(number): - # NetworkX accepts arbitrary python objects as attributes, but: - # * the GEXF writer does not accept any NumPy types (on signals), - # * the GraphML writer does not accept NumPy ints. - if issubclass(number.dtype.type, (np.integer, np.bool_)): - return int(number) - else: - return float(number) - - def edges(): - for source, target, weight in zip(*self.get_edge_list()): - yield source, target, {'weight': convert(weight)} - - def nodes(): - for vertex in range(self.n_vertices): - signals = {name: convert(signal[vertex]) - for name, signal in self.signals.items()} - yield vertex, signals - - self._break_signals() - graph = nx.DiGraph() if self.is_directed() else nx.Graph() - graph.add_nodes_from(nodes()) - graph.add_edges_from(edges()) - graph.name = self.__class__.__name__ - return graph - - def to_graphtool(self): - r"""Export the graph to graph-tool. - - Edge weights are stored as an edge property map, - under the name "weight". - - Signals are stored as vertex property maps, - under their name in the :attr:`signals` dictionary. - `N`-dimensional signals are broken into `N` 1-dimensional signals. - They will eventually be joined back together on import. - - Returns - ------- - graph : :class:`graph_tool.Graph` - A graph-tool graph object. - - See also - -------- - to_networkx : export to NetworkX - save : save to a file - - Examples - -------- - >>> import graph_tool as gt - >>> import graph_tool.draw - >>> from matplotlib import pyplot as plt - >>> graph = graphs.Path(4, directed=True) - >>> graph.set_signal(np.full(4, 2.3), 'signal') - >>> graph = graph.to_graphtool() - >>> graph.is_directed() - True - >>> graph.vertex_properties['signal'][2] - 2.3 - >>> graph.edge_properties['weight'][(0, 1)] - 1.0 - >>> # gt.draw.graph_draw(graph, vertex_text=graph.vertex_index) - - Another common goal is to use graph-tool to compute some properties to - be imported back in the PyGSP as signals. - - >>> import graph_tool as gt - >>> import graph_tool.centrality - >>> from matplotlib import pyplot as plt - >>> graph = graphs.Sensor(100, seed=42) - >>> graph.set_signal(graph.coords, 'coords') - >>> graph = graph.to_graphtool() - >>> vprop, eprop = gt.centrality.betweenness( - ... graph, weight=graph.edge_properties['weight']) - >>> graph.vertex_properties['betweenness'] = vprop - >>> graph = graphs.Graph.from_graphtool(graph) - >>> graph.compute_fourier_basis() - >>> graph.set_coordinates(graph.signals['coords']) - >>> fig, axes = plt.subplots(1, 2) - >>> _ = graph.plot(graph.signals['betweenness'], ax=axes[0]) - >>> _ = axes[1].plot(graph.e, graph.gft(graph.signals['betweenness'])) - - """ - - # See gt.value_types() for the list of accepted types. - # See the definition of _type_alias() for a list of aliases. - # Mapping from https://docs.scipy.org/doc/numpy/user/basics.types.html. - convert = { - np.bool_: 'bool', - np.int8: 'int8_t', - np.int16: 'int16_t', - np.int32: 'int32_t', - np.int64: 'int64_t', - np.short: 'short', - np.intc: 'int', - np.uintc: 'unsigned int', - np.long: 'long', - np.longlong: 'long long', - np.uint: 'unsigned long', - np.single: 'float', - np.double: 'double', - np.longdouble: 'long double', - } - - gt = _import_graphtool() - graph = gt.Graph(directed=self.is_directed()) - - sources, targets, weights = self.get_edge_list() - graph.add_edge_list(np.asarray((sources, targets)).T) - try: - dtype = convert[weights.dtype.type] - except KeyError: - raise ValueError("Type {} of the edge weights is not supported." - .format(weights.dtype)) - prop = graph.new_edge_property(dtype) - prop.get_array()[:] = weights - graph.edge_properties['weight'] = prop - - self._break_signals() - for name, signal in self.signals.items(): - try: - dtype = convert[signal.dtype.type] - except KeyError: - raise ValueError("Type {} of signal {} is not supported." - .format(signal.dtype, name)) - prop = graph.new_vertex_property(dtype) - prop.get_array()[:] = signal - graph.vertex_properties[name] = prop - - return graph - - @classmethod - def from_networkx(cls, graph, weight='weight'): - r"""Import a graph from NetworkX. - - Edge weights are retrieved as an edge attribute, - under the name specified by the ``weight`` parameter. - - Signals are retrieved from node attributes, - and stored in the :attr:`signals` dictionary under the attribute name. - `N`-dimensional signals that were broken during export are joined. - - Parameters - ---------- - graph : :class:`networkx.Graph` - A NetworkX graph object. - weight : string or None, optional - The edge attribute that holds the numerical values used as the edge - weights. All edge weights are set to 1 if None, or not found. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - A PyGSP graph object. - - Notes - ----- - - The nodes are ordered according to :meth:`networkx.Graph.nodes`. - - In NetworkX, node attributes need not be set for every node. - If a node attribute is not set for a node, a NaN is assigned to the - corresponding signal for that node. - - If the graph is a :class:`networkx.MultiGraph`, multiedges are - aggregated by summation. - - See also - -------- - from_graphtool : import from graph-tool - load : load from a file - - Examples - -------- - >>> import networkx as nx - >>> graph = nx.Graph() - >>> graph.add_edge(1, 2, weight=0.2) - >>> graph.add_edge(2, 3, weight=0.9) - >>> graph.add_node(4, sig=3.1416) - >>> graph.nodes() - NodeView((1, 2, 3, 4)) - >>> graph = graphs.Graph.from_networkx(graph) - >>> graph.W.toarray() - array([[0. , 0.2, 0. , 0. ], - [0.2, 0. , 0.9, 0. ], - [0. , 0.9, 0. , 0. ], - [0. , 0. , 0. , 0. ]]) - >>> graph.signals - {'sig': array([ nan, nan, nan, 3.1416])} - - """ - nx = _import_networkx() - - adjacency = nx.to_scipy_sparse_matrix(graph, weight=weight) - graph_pg = Graph(adjacency) - - for i, node in enumerate(graph.nodes()): - for name in graph.nodes[node].keys(): - try: - signal = graph_pg.signals[name] - except KeyError: - signal = np.full(graph_pg.n_vertices, np.nan) - graph_pg.set_signal(signal, name) - try: - signal[i] = graph.nodes[node][name] - except KeyError: - pass # attribute not set for node - - graph_pg._join_signals() - return graph_pg - - @classmethod - def from_graphtool(cls, graph, weight='weight'): - r"""Import a graph from graph-tool. - - Edge weights are retrieved as an edge property, - under the name specified by the ``weight`` parameter. + def set_signal(self, signal, name): + r"""Attach a signal to the graph. - Signals are retrieved from node properties, - and stored in the :attr:`signals` dictionary under the property name. - `N`-dimensional signals that were broken during export are joined. + Attached signals can be accessed (and modified or deleted) through the + :attr:`signals` dictionary. Parameters ---------- - graph : :class:`graph_tool.Graph` - A graph-tool graph object. - weight : string - The edge property that holds the numerical values used as the edge - weights. All edge weights are set to 1 if None, or not found. - - Returns - ------- - graph : :class:`~pygsp.graphs.Graph` - A PyGSP graph object. - - Notes - ----- - - If the graph has multiple edge connecting the same two nodes, a sum - over the edges is taken to merge them. - - See also - -------- - from_networkx : import from NetworkX - load : load from a file + signal : array_like + A sequence that assigns a value to each vertex. + The value of the signal at vertex `i` is ``signal[i]``. + name : String + Name of the signal used as a key in the :attr:`signals` dictionary. Examples -------- - >>> import graph_tool as gt - >>> graph = gt.Graph(directed=False) - >>> e1 = graph.add_edge(0, 1) - >>> e2 = graph.add_edge(1, 2) - >>> v = graph.add_vertex() - >>> eprop = graph.new_edge_property("double") - >>> eprop[e1] = 0.2 - >>> eprop[(1, 2)] = 0.9 - >>> graph.edge_properties["weight"] = eprop - >>> vprop = graph.new_vertex_property("double", val=np.nan) - >>> vprop[3] = 3.1416 - >>> graph.vertex_properties["sig"] = vprop - >>> graph = graphs.Graph.from_graphtool(graph) - >>> graph.W.toarray() - array([[0. , 0.2, 0. , 0. ], - [0.2, 0. , 0.9, 0. ], - [0. , 0.9, 0. , 0. ], - [0. , 0. , 0. , 0. ]]) + >>> graph = graphs.Sensor(10) + >>> signal = np.arange(graph.n_vertices) + >>> graph.set_signal(signal, 'mysignal') >>> graph.signals - {'sig': PropertyArray([ nan, nan, nan, 3.1416])} + {'mysignal': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])} """ - gt = _import_graphtool() - import graph_tool.spectral - - weight = graph.edge_properties.get(weight, None) - adjacency = gt.spectral.adjacency(graph, weight=weight) - graph_pg = Graph(adjacency.T) - - for name, signal in graph.vertex_properties.items(): - graph_pg.set_signal(signal.get_array(), name) - - graph_pg._join_signals() - return graph_pg - - @classmethod - def load(cls, path, fmt=None, backend=None): - r"""Load a graph from a file. - - Edge weights are retrieved as an edge attribute named "weight". - - Signals are retrieved from node attributes, - and stored in the :attr:`signals` dictionary under the attribute name. - `N`-dimensional signals that were broken during export are joined. + signal = self._check_signal(signal) + self.signals[name] = signal - Parameters - ---------- - path : string - Path to the file from which to load the graph. - fmt : {'graphml', 'gml', 'gexf', None}, optional - Format in which the graph is saved. - Guessed from the filename extension if None. - backend : {'networkx', 'graph-tool', None}, optional - Library used to load the graph. Automatically chosen if None. + def check_weights(self): + r"""Check the characteristics of the weights matrix. Returns ------- - graph : :class:`Graph` - The loaded graph. + A dict of bools containing informations about the matrix - See also - -------- - save : save a graph to a file - from_networkx : load with NetworkX then import in the PyGSP - from_graphtool : load with graph-tool then import in the PyGSP - - Notes - ----- - - A lossless round-trip is only guaranteed if the graph (and its signals) - is saved and loaded with the same backend. - - Loading from other formats is possible by loading in NetworkX or - graph-tool, and importing to the PyGSP. - The proposed formats are however tested for faithful round-trips. + has_inf_val : bool + True if the matrix has infinite values else false + has_nan_value : bool + True if the matrix has a "not a number" value else false + is_not_square : bool + True if the matrix is not square else false + diag_is_not_zero : bool + True if the matrix diagonal has not only zeros else false Examples -------- - >>> graph = graphs.Logo() - >>> graph.save('logo.graphml') - >>> graph = graphs.Graph.load('logo.graphml') - >>> import os - >>> os.remove('logo.graphml') - - """ - - if fmt is None: - fmt = os.path.splitext(path)[1][1:] - if fmt not in ['graphml', 'gml', 'gexf']: - raise ValueError('Unsupported format {}.'.format(fmt)) - - def load_networkx(path, fmt): - nx = _import_networkx() - load = getattr(nx, 'read_' + fmt) - graph = load(path) - return cls.from_networkx(graph) - - def load_graphtool(path, fmt): - gt = _import_graphtool() - graph = gt.load_graph(path, fmt=fmt) - return cls.from_graphtool(graph) - - if backend == 'networkx': - return load_networkx(path, fmt) - elif backend == 'graph-tool': - return load_graphtool(path, fmt) - elif backend is None: - try: - return load_networkx(path, fmt) - except ImportError: - try: - return load_graphtool(path, fmt) - except ImportError: - raise ImportError('Cannot import networkx nor graph-tool.') - else: - raise ValueError('Unknown backend {}.'.format(backend)) - - def save(self, path, fmt=None, backend=None): - r"""Save the graph to a file. - - Edge weights are stored as an edge attribute, - under the name "weight". - - Signals are stored as node attributes, - under their name in the :attr:`signals` dictionary. - `N`-dimensional signals are broken into `N` 1-dimensional signals. - They will eventually be joined back together on import. - - Parameters - ---------- - path : string - Path to the file where the graph is to be saved. - fmt : {'graphml', 'gml', 'gexf', None}, optional - Format in which to save the graph. - Guessed from the filename extension if None. - backend : {'networkx', 'graph-tool', None}, optional - Library used to load the graph. Automatically chosen if None. - - See also - -------- - load : load a graph from a file - to_networkx : export as a NetworkX graph, and save with NetworkX - to_graphtool : export as a graph-tool graph, and save with graph-tool - - Notes - ----- - - A lossless round-trip is only guaranteed if the graph (and its signals) - is saved and loaded with the same backend. - - Saving in other formats is possible by exporting to NetworkX or - graph-tool, and using their respective saving functionality. - The proposed formats are however tested for faithful round-trips. - - Edge weights and signal values are rounded at the sixth decimal when - saving in ``fmt='gml'`` with ``backend='graph-tool'``. - - Examples - -------- - >>> graph = graphs.Logo() - >>> graph.save('logo.graphml') - >>> graph = graphs.Graph.load('logo.graphml') - >>> import os - >>> os.remove('logo.graphml') + >>> W = np.arange(4).reshape(2, 2) + >>> G = graphs.Graph(W) + >>> cw = G.check_weights() + >>> cw == {'has_inf_val': False, 'has_nan_value': False, + ... 'is_not_square': False, 'diag_is_not_zero': True} + True """ - if fmt is None: - fmt = os.path.splitext(path)[1][1:] - if fmt not in ['graphml', 'gml', 'gexf']: - raise ValueError('Unsupported format {}.'.format(fmt)) - - def save_networkx(graph, path, fmt): - nx = _import_networkx() - graph = graph.to_networkx() - save = getattr(nx, 'write_' + fmt) - save(graph, path) - - def save_graphtool(graph, path, fmt): - graph = graph.to_graphtool() - graph.save(path, fmt=fmt) - - if backend == 'networkx': - save_networkx(self, path, fmt) - elif backend == 'graph-tool': - save_graphtool(self, path, fmt) - elif backend is None: - try: - save_networkx(self, path, fmt) - except ImportError: - try: - save_graphtool(self, path, fmt) - except ImportError: - raise ImportError('Cannot import networkx nor graph-tool.') - else: - raise ValueError('Unknown backend {}.'.format(backend)) + has_inf_val = False + diag_is_not_zero = False + is_not_square = False + has_nan_value = False - def set_signal(self, signal, name): - r"""Attach a signal to the graph. + if np.isinf(self.W.sum()): + self.logger.warning('There is an infinite ' + 'value in the weight matrix!') + has_inf_val = True - Attached signals can be accessed (and modified or deleted) through the - :attr:`signals` dictionary. + if abs(self.W.diagonal()).sum() != 0: + self.logger.warning('The main diagonal of ' + 'the weight matrix is not 0!') + diag_is_not_zero = True - Parameters - ---------- - signal : array_like - A sequence that assigns a value to each vertex. - The value of the signal at vertex `i` is ``signal[i]``. - name : String - Name of the signal used as a key in the :attr:`signals` dictionary. + if self.W.get_shape()[0] != self.W.get_shape()[1]: + self.logger.warning('The weight matrix is not square!') + is_not_square = True - Examples - -------- - >>> graph = graphs.Sensor(10) - >>> signal = np.arange(graph.n_vertices) - >>> graph.set_signal(signal, 'mysignal') - >>> graph.signals - {'mysignal': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])} + if np.isnan(self.W.sum()): + self.logger.warning('There is a NaN value in the weight matrix!') + has_nan_value = True - """ - signal = self._check_signal(signal) - self.signals[name] = signal + return {'has_inf_val': has_inf_val, + 'has_nan_value': has_nan_value, + 'is_not_square': is_not_square, + 'diag_is_not_zero': diag_is_not_zero} def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). @@ -1644,105 +1144,3 @@ def plot_spectrogram(self, node_idx=None): r"""Docstring overloaded at import time.""" from pygsp.plotting import _plot_spectrogram _plot_spectrogram(self, node_idx=node_idx) - - def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], - iterations=50, scale=1.0, center=None, - seed=None): - # TODO doc - # fixed: list of nodes with fixed coordinates - # Position nodes using Fruchterman-Reingold force-directed algorithm. - - if center is None: - center = np.zeros((1, dim)) - - if np.shape(center)[1] != dim: - self.logger.error('Spring coordinates: center has wrong size.') - center = np.zeros((1, dim)) - - if pos is None: - dom_size = 1 - pos_arr = None - else: - # Determine size of existing domain to adjust initial positions - dom_size = np.max(pos) - pos_arr = np.random.RandomState(seed).uniform(size=(self.N, dim)) - pos_arr = pos_arr * dom_size + center - for i in range(self.N): - pos_arr[i] = np.asanyarray(pos[i]) - - if k is None and len(fixed) > 0: - # We must adjust k by domain size for layouts that are not near 1x1 - k = dom_size / np.sqrt(self.N) - - pos = _sparse_fruchterman_reingold(self.A, dim, k, pos_arr, - fixed, iterations, seed) - - if len(fixed) == 0: - pos = _rescale_layout(pos, scale=scale) + center - - return pos - - -def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): - # Position nodes in adjacency matrix A using Fruchterman-Reingold - nnodes = A.shape[0] - - # make sure we have a LIst of Lists representation - try: - A = A.tolil() - except Exception: - A = (sparse.coo_matrix(A)).tolil() - - if pos is None: - # random initial positions - pos = np.random.RandomState(seed).uniform(size=(nnodes, dim)) - - # optimal distance between nodes - if k is None: - k = np.sqrt(1.0 / nnodes) - - # simple cooling scheme. - # linearly step down by dt on each iteration so last iteration is size dt. - t = 0.1 - dt = t / float(iterations + 1) - - displacement = np.zeros((dim, nnodes)) - for iteration in range(iterations): - displacement *= 0 - # loop over rows - for i in range(nnodes): - if i in fixed: - continue - # difference between this row's node position and all others - delta = (pos[i] - pos).T - # distance between points - distance = np.sqrt((delta**2).sum(axis=0)) - # enforce minimum distance of 0.01 - distance = np.where(distance < 0.01, 0.01, distance) - # the adjacency matrix row - Ai = A[i, :].toarray() - # displacement "force" - displacement[:, i] += \ - (delta * (k * k / distance**2 - Ai * distance / k)).sum(axis=1) - # update positions - length = np.sqrt((displacement**2).sum(axis=0)) - length = np.where(length < 0.01, 0.1, length) - pos += (displacement * t / length).T - # cool temperature - t -= dt - - return pos - - -def _rescale_layout(pos, scale=1): - # rescale to (-scale, scale) in all axes - - # shift origin to (0,0) - lim = 0 # max coordinate for all axes - for i in range(pos.shape[1]): - pos[:, i] -= pos[:, i].mean() - lim = max(pos[:, i].max(), lim) - # rescale to (-scale,scale) in all directions, preserves aspect - for i in range(pos.shape[1]): - pos[:, i] *= scale / lim - return pos From ec36a2633715c7d805380bc4fefbcd604f01a112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:03:46 +0100 Subject: [PATCH 326/365] test: fix unknown backend --- pygsp/tests/test_graphs.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index fc022509..7a299b3e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -804,6 +804,7 @@ def test_save_load(self): self.assertRaises(ValueError, G1.save, 'g', fmt, backend) self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt, backend) + os.remove('g') continue atol = 1e-5 if fmt == 'gml' and backend == 'graph-tool' else 0 @@ -815,11 +816,10 @@ def test_save_load(self): np.testing.assert_allclose(G2.signals['s'], sig, atol=atol) os.remove(filename) - self.assertRaises(ValueError, graphs.Graph.load, 'g', fmt='unk') - self.assertRaises(ValueError, graphs.Graph.load, 'g', backend='unk') - self.assertRaises(ValueError, G1.save, 'g', fmt='unk') - self.assertRaises(ValueError, G1.save, 'g', backend='unk') - os.remove('g') + self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', fmt='?') + self.assertRaises(ValueError, graphs.Graph.load, 'g.gml', backend='?') + self.assertRaises(ValueError, G1.save, 'g.gml', fmt='?') + self.assertRaises(ValueError, G1.save, 'g.gml', backend='?') @unittest.skipIf(sys.version_info < (3, 3), 'need unittest.mock') def test_import_errors(self): From 45239559b5fa9a1b7a817d0b1a509304f9e6f9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:13:11 +0100 Subject: [PATCH 327/365] test for missing networkx node attribute --- pygsp/tests/test_graphs.py | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 7a299b3e..4d0e4fde 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -711,23 +711,12 @@ def test_graphtool_signal_import(self): self.assertEqual(g.signals["signal"][2], 2.4) def test_networkx_signal_import(self): - g_nx = nx.Graph() - g_nx.add_edge(3, 4) - g_nx.add_edge(2, 4) - g_nx.add_edge(3, 5) - dic_signal = { - 2: 4.0, - 3: 5.0, - 4: 3.3, - 5: 2.3 - } - - nx.set_node_attributes(g_nx, dic_signal, "signal1") - g = graphs.Graph.from_networkx(g_nx) - - for i, node in enumerate(g_nx.node): - self.assertEqual(g.signals["signal1"][i], - nx.get_node_attributes(g_nx, "signal1")[node]) + graph_nx = nx.Graph() + graph_nx.add_nodes_from(range(2, 5)) + graph_nx.add_edges_from([(3, 4), (2, 4), (3, 5)]) + nx.set_node_attributes(graph_nx, {2: 4, 3: 5, 5: 2.3}, "s") + graph_pg = graphs.Graph.from_networkx(graph_nx) + np.testing.assert_allclose(graph_pg.signals["s"], [4, 5, np.nan, 2.3]) def test_no_weights(self): From 9b9429e09a6235d9c8e92eacf79146b8d5e2301c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 16:19:29 +0100 Subject: [PATCH 328/365] test export of invalid signal type --- pygsp/graphs/_io.py | 8 ++++---- pygsp/tests/test_graphs.py | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 9748ea3b..77d8b709 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -229,8 +229,8 @@ def to_graphtool(self): try: dtype = convert[weights.dtype.type] except KeyError: - raise ValueError("Type {} of the edge weights is not supported." - .format(weights.dtype)) + raise TypeError("Type {} of the edge weights is not supported." + .format(weights.dtype)) prop = graph.new_edge_property(dtype) prop.get_array()[:] = weights graph.edge_properties['weight'] = prop @@ -240,8 +240,8 @@ def to_graphtool(self): try: dtype = convert[signal.dtype.type] except KeyError: - raise ValueError("Type {} of signal {} is not supported." - .format(signal.dtype, name)) + raise TypeError("Type {} of signal {} is not supported." + .format(signal.dtype, name)) prop = graph.new_vertex_property(dtype) prop.get_array()[:] = signal graph.vertex_properties[name] = prop diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4d0e4fde..1a5e8684 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -675,6 +675,10 @@ def test_networkx_signal_export(self): for i in range(graph.N): self.assertEqual(graph_nx.node[i]["signal1"], signal1[i]) self.assertEqual(graph_nx.node[i]["signal2"], signal2[i]) + # invalid signal type + graph = graphs.Path(3) + graph.set_signal(np.array(['a', 'b', 'c']), 'sig') + self.assertRaises(ValueError, graph.to_networkx) def test_graphtool_signal_export(self): g = graphs.Logo() @@ -688,6 +692,10 @@ def test_graphtool_signal_export(self): for i, v in enumerate(g_gt.vertices()): self.assertEqual(g_gt.vertex_properties["signal1"][v], s[i]) self.assertEqual(g_gt.vertex_properties["signal2"][v], s2[i]) + # invalid signal type + graph = graphs.Path(3) + graph.set_signal(np.array(['a', 'b', 'c']), 'sig') + self.assertRaises(TypeError, graph.to_graphtool) @unittest.skipIf(sys.version_info < (3,), 'old graph-tool') def test_graphtool_signal_import(self): From 9262e942f22db6ad72e2edca3238a5df9c58c36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:42:32 +0100 Subject: [PATCH 329/365] doc: See Also as numpy docstring standard --- pygsp/graphs/_io.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 77d8b709..441fcf83 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -66,7 +66,7 @@ def to_networkx(self): graph : :class:`networkx.Graph` A NetworkX graph object. - See also + See Also -------- to_graphtool : export to graph-tool save : save to a file @@ -159,7 +159,7 @@ def to_graphtool(self): graph : :class:`graph_tool.Graph` A graph-tool graph object. - See also + See Also -------- to_networkx : export to NetworkX save : save to a file @@ -284,7 +284,7 @@ def from_networkx(cls, graph, weight='weight'): If the graph is a :class:`networkx.MultiGraph`, multiedges are aggregated by summation. - See also + See Also -------- from_graphtool : import from graph-tool load : load from a file @@ -359,7 +359,7 @@ def from_graphtool(cls, graph, weight='weight'): If the graph has multiple edge connecting the same two nodes, a sum over the edges is taken to merge them. - See also + See Also -------- from_networkx : import from NetworkX load : load from a file @@ -427,7 +427,7 @@ def load(cls, path, fmt=None, backend=None): graph : :class:`Graph` The loaded graph. - See also + See Also -------- save : save a graph to a file from_networkx : load with NetworkX then import in the PyGSP @@ -505,7 +505,7 @@ def save(self, path, fmt=None, backend=None): backend : {'networkx', 'graph-tool', None}, optional Library used to load the graph. Automatically chosen if None. - See also + See Also -------- load : load a graph from a file to_networkx : export as a NetworkX graph, and save with NetworkX From bdb303cb5e0311e86c9ff8fd4f6708f5cef6ed71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:45:37 +0100 Subject: [PATCH 330/365] doc: some cross references --- pygsp/graphs/grid2d.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 26bef79b..795d103f 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -28,6 +28,10 @@ class Grid2d(Graph): Torus : Kronecker product of two ring graphs Grid2dImgPatches + See Also + -------- + Grid2dImgPatches + Examples -------- >>> import matplotlib.pyplot as plt From 93525996498755613b298f872efe369600bed56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Mar 2019 17:55:59 +0100 Subject: [PATCH 331/365] doc: path and DCT, ring and DFT --- doc/history.rst | 4 ++++ pygsp/graphs/grid2d.py | 6 ------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index 2e002bb4..ed51e050 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -33,7 +33,11 @@ History ======= * Import and export graphs and their signals to NetworkX and graph-tool. * Save and load graphs and theirs signals to / from GraphML, GML, and GEXF. +<<<<<<< HEAD >>>>>>> update I/O doc +======= +* Documentation: path graph linked to DCT, ring graph linked to DFT. +>>>>>>> doc: path and DCT, ring and DFT * Merged all the extra requirements in a single dev requirement. >>>>>>> merge all the extra requirements in a single dev requirement diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 795d103f..81ae41a7 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,8 +19,6 @@ class Grid2d(Graph): Number of vertices along the first dimension. N2 : int Number of vertices along the second dimension. Default is ``N1``. - diagonal : float - Value of the diagnal edges. Default is ``0.0`` See Also -------- @@ -28,10 +26,6 @@ class Grid2d(Graph): Torus : Kronecker product of two ring graphs Grid2dImgPatches - See Also - -------- - Grid2dImgPatches - Examples -------- >>> import matplotlib.pyplot as plt From 8bd24470cd0b3fc709fd4cc80ac538ae53c312eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Mar 2019 01:18:37 +0100 Subject: [PATCH 332/365] doc: graph formats and manipulation + visualization software --- pygsp/graphs/_io.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index 441fcf83..f44cefd1 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -495,6 +495,34 @@ def save(self, path, fmt=None, backend=None): `N`-dimensional signals are broken into `N` 1-dimensional signals. They will eventually be joined back together on import. + Supported formats are: + + * GraphML_, a comprehensive XML format. + `Wikipedia `_. + Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, + Cytoscape_, SocNetV_. + * GML_ (Graph Modelling Language), a simple non-XML format. + `Wikipedia `_. + Supported by NetworkX_, graph-tool_, NetworKit_, igraph_, Gephi_, + Cytoscape_, SocNetV_, Tulip_. + * GEXF_ (Graph Exchange XML Format), Gephi's XML format. + Supported by NetworkX_, NetworKit_, Gephi_, Tulip_, ngraph_. + + If unsure, we recommend GraphML_. + + .. _GraphML: http://graphml.graphdrawing.org + .. _GML: http://www.infosun.fim.uni-passau.de/Graphlet/GML/gml-tr.html + .. _GEXF: https://gephi.org/gexf/format + .. _NetworkX: https://networkx.github.io + .. _graph-tool: https://graph-tool.skewed.de + .. _NetworKit: https://networkit.github.io + .. _igraph: https://igraph.org + .. _ngraph: https://github.com/anvaka/ngraph + .. _Gephi: https://gephi.org + .. _Cytoscape: https://cytoscape.org + .. _SocNetV: https://socnetv.org + .. _Tulip: http://tulip.labri.fr + Parameters ---------- path : string From 51afe79c099ae9d253373e1588692da354e1418f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Mar 2019 03:12:17 +0100 Subject: [PATCH 333/365] to_networkx: convert numpy int to python int nx only properly deals with python objects --- pygsp/graphs/_io.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/_io.py b/pygsp/graphs/_io.py index f44cefd1..3ac774c1 100644 --- a/pygsp/graphs/_io.py +++ b/pygsp/graphs/_io.py @@ -128,7 +128,7 @@ def convert(number): def edges(): for source, target, weight in zip(*self.get_edge_list()): - yield source, target, {'weight': convert(weight)} + yield int(source), int(target), {'weight': convert(weight)} def nodes(): for vertex in range(self.n_vertices): From 1a8349f8c34c87300b6fa253b2893943ac7f7daa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Mar 2019 23:05:42 +0100 Subject: [PATCH 334/365] subgraph: sub-sample signals as well --- pygsp/graphs/graph.py | 8 ++++++-- pygsp/tests/test_graphs.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 17dad05c..db393ebe 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -371,7 +371,8 @@ def subgraph(self, vertices): Parameters ---------- vertices : list - List of vertices to keep. + Vertices to keep. + Either a list of indices or an indicator function. Returns ------- @@ -398,7 +399,10 @@ def subgraph(self, vertices): coords = self.coords[vertices] except AttributeError: coords = None - return Graph(adjacency, self.lap_type, coords, self.plotting) + graph = Graph(adjacency, self.lap_type, coords, self.plotting) + for name, signal in self.signals.items(): + graph.set_signal(signal[vertices], name) + return graph def is_connected(self): r"""Check if the graph is connected (cached). diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 1a5e8684..54ee93f2 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -448,9 +448,11 @@ def test_set_coordinates(self): def test_subgraph(self, n_vertices=100): + self._G.set_signal(self._G.coords, 'coords') graph = self._G.subgraph(range(n_vertices)) self.assertEqual(graph.n_vertices, n_vertices) self.assertEqual(graph.coords.shape, (n_vertices, 2)) + self.assertEqual(graph.signals['coords'].shape, (n_vertices, 2)) self.assertIs(graph.lap_type, self._G.lap_type) self.assertEqual(graph.plotting, self._G.plotting) From 65e5767b086e7b68ce8282f4d2068d666f4e69c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Mar 2019 22:43:56 +0100 Subject: [PATCH 335/365] doc: use sphinx-gallery for short examples --- .gitignore | 3 +-- doc/conf.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 9c15768f..f56c8f74 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,8 @@ output/*.html output/*/index.html # Sphinx documentation -/doc/_build/ +/doc/_build /doc/examples/ -/doc/backrefs/ # Vim swap files .*.swp diff --git a/doc/conf.py b/doc/conf.py index 569c1f04..25f6d73d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -44,9 +44,9 @@ 'examples_dirs': '../examples', 'gallery_dirs': 'examples', 'filename_pattern': '/', - 'reference_url': {'pygsp': None}, - 'backreferences_dir': 'backrefs', - 'doc_module': 'pygsp', + 'reference_url': { + 'pygsp': None, + }, 'show_memory': True, } From 1140afc2df6397fdbb63697a28cd057ca8ed77a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 28 Mar 2019 23:12:34 +0100 Subject: [PATCH 336/365] doc: eigenvalue concentration example --- examples/eigenvalue_concentration.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py index c372bf2a..d9fcdd86 100644 --- a/examples/eigenvalue_concentration.py +++ b/examples/eigenvalue_concentration.py @@ -6,15 +6,13 @@ graph becomes full. """ -import numpy as np from matplotlib import pyplot as plt import pygsp as pg n_neighbors = [1, 2, 5, 8] -fig, axes = plt.subplots(3, len(n_neighbors), figsize=(15, 8)) +fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) for k, ax in zip(n_neighbors, axes.T): - graph = pg.graphs.Ring(17, k=k) graph.compute_fourier_basis() graph.plot(graph.U[:, 1], ax=ax[0]) @@ -22,17 +20,7 @@ ax[1].spy(graph.W) ax[2].plot(graph.e, '.') ax[2].set_title('k={}'.format(k)) - #graph.set_coordinates('line1D') - #graph.plot(graph.U[:, :4], ax=ax[3], title='') - - # Check that the DFT matrix is an eigenbasis of the Laplacian. - U = np.fft.fft(np.identity(graph.n_vertices)) - LambdaM = (graph.L.todense().dot(U)) / (U + 1e-15) - # Eigenvalues should be real. - assert np.all(np.abs(np.imag(LambdaM)) < 1e-10) - LambdaM = np.real(LambdaM) - # Check that the eigenvectors are really eigenvectors of the laplacian. - Lambda = np.mean(LambdaM, axis=0) - assert np.all(np.abs(LambdaM - Lambda) < 1e-10) + graph.set_coordinates('line1D') + graph.plot(graph.U[:, :4], ax=ax[3], title='') fig.tight_layout() From 8437127b2a255e218031e5d116fac6d6d5542d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:01:34 +0100 Subject: [PATCH 337/365] doc gallery: need this config for intersphinx links to work see https://github.com/sphinx-gallery/sphinx-gallery/issues/467 --- .gitignore | 3 ++- doc/conf.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index f56c8f74..9c15768f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,9 @@ output/*.html output/*/index.html # Sphinx documentation -/doc/_build +/doc/_build/ /doc/examples/ +/doc/backrefs/ # Vim swap files .*.swp diff --git a/doc/conf.py b/doc/conf.py index 25f6d73d..569c1f04 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -44,9 +44,9 @@ 'examples_dirs': '../examples', 'gallery_dirs': 'examples', 'filename_pattern': '/', - 'reference_url': { - 'pygsp': None, - }, + 'reference_url': {'pygsp': None}, + 'backreferences_dir': 'backrefs', + 'doc_module': 'pygsp', 'show_memory': True, } From 87e0d03106c39c9d9dac79b9529f13c760369c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:13:06 +0100 Subject: [PATCH 338/365] doc: more examples from my talks --- examples/filtering.py | 4 ++++ examples/fourier_basis.py | 4 ++++ examples/fourier_transform.py | 4 ++++ examples/heat_diffusion.py | 10 +++++++--- examples/kernel_localization.py | 4 ++++ examples/random_walk.py | 10 +++++++--- examples/wave_propagation.py | 10 +++++++--- 7 files changed, 37 insertions(+), 9 deletions(-) diff --git a/examples/filtering.py b/examples/filtering.py index 62256685..d53e388f 100644 --- a/examples/filtering.py +++ b/examples/filtering.py @@ -21,6 +21,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/fourier_basis.py b/examples/fourier_basis.py index 1064c58f..e99126ad 100644 --- a/examples/fourier_basis.py +++ b/examples/fourier_basis.py @@ -16,6 +16,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + n_eigenvectors = 7 fig, axes = plt.subplots(2, 7, figsize=(15, 4)) diff --git a/examples/fourier_transform.py b/examples/fourier_transform.py index 2589ef12..8e99969c 100644 --- a/examples/fourier_transform.py +++ b/examples/fourier_transform.py @@ -12,6 +12,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py index 81701bbe..8ea42608 100644 --- a/examples/heat_diffusion.py +++ b/examples/heat_diffusion.py @@ -12,6 +12,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() @@ -28,7 +32,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Heat(G, scale=t) - title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) + title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +40,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] + labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() diff --git a/examples/kernel_localization.py b/examples/kernel_localization.py index d9484fa9..5bbd2225 100644 --- a/examples/kernel_localization.py +++ b/examples/kernel_localization.py @@ -13,6 +13,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + fig, axes = plt.subplots(2, 4, figsize=(10, 4)) graphs = [ diff --git a/examples/random_walk.py b/examples/random_walk.py index 9fc69456..b3031b4c 100644 --- a/examples/random_walk.py +++ b/examples/random_walk.py @@ -13,6 +13,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + N = 7 steps = [0, 1, 2, 3] @@ -20,11 +24,11 @@ delta = np.zeros(graph.N) delta[N//2*N + N//2] = 1 -probability = sparse.diags(graph.dw**(-1)).dot(graph.W) +probability = sparse.diags(graph.dw**(-1)) @ graph.W fig, axes = plt.subplots(1, len(steps), figsize=(12, 3)) for step, ax in zip(steps, axes): - state = (probability**step).__rmatmul__(delta) ## = delta @ probability**step + state = delta @ probability**step graph.plot(state, ax=ax, title=r'$\delta P^{}$'.format(step)) ax.set_axis_off() @@ -47,7 +51,7 @@ if not hasattr(graph, 'coords'): graph.set_coordinates(seed=10) - P = sparse.diags(graph.dw**(-1)).dot(graph.W) + P = sparse.diags(graph.dw**(-1)) @ graph.W # e, u = np.linalg.eig(P.T.toarray()) # np.testing.assert_allclose(np.linalg.inv(u.T) @ np.diag(e) @ u.T, diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py index ec69b73c..2abf71d9 100644 --- a/examples/wave_propagation.py +++ b/examples/wave_propagation.py @@ -12,6 +12,10 @@ from matplotlib import pyplot as plt import pygsp as pg +#plt.rc('font', family='Latin Modern Roman') +plt.rc('text', usetex=True) +plt.rc('text.latex', preamble=r'\usepackage{lmodern}') + n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() @@ -28,7 +32,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Wave(G, time=t, speed=1) - title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) + title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +40,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] + labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() From c117771033960f957b532718c9651222c5bc297b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:25:21 +0100 Subject: [PATCH 339/365] history: examples gallery --- doc/history.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/history.rst b/doc/history.rst index ed51e050..f87b7a9d 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -37,7 +37,12 @@ History >>>>>>> update I/O doc ======= * Documentation: path graph linked to DCT, ring graph linked to DFT. +<<<<<<< HEAD >>>>>>> doc: path and DCT, ring and DFT +======= +* We now have a gallery of examples! That is convenient for users to get a + taste of what the library can do, and to start working from a code snippet. +>>>>>>> history: examples gallery * Merged all the extra requirements in a single dev requirement. >>>>>>> merge all the extra requirements in a single dev requirement From e322b4455c1b6e12ed9a2d7dda6e224100604f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 01:29:15 +0100 Subject: [PATCH 340/365] examples: while math looks better (especially for papers and slides), latex is not available everywhere --- examples/filtering.py | 4 ---- examples/fourier_basis.py | 4 ---- examples/fourier_transform.py | 4 ---- examples/heat_diffusion.py | 4 ---- examples/kernel_localization.py | 4 ---- examples/random_walk.py | 4 ---- examples/wave_propagation.py | 4 ---- 7 files changed, 28 deletions(-) diff --git a/examples/filtering.py b/examples/filtering.py index d53e388f..62256685 100644 --- a/examples/filtering.py +++ b/examples/filtering.py @@ -21,10 +21,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/fourier_basis.py b/examples/fourier_basis.py index e99126ad..1064c58f 100644 --- a/examples/fourier_basis.py +++ b/examples/fourier_basis.py @@ -16,10 +16,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_eigenvectors = 7 fig, axes = plt.subplots(2, 7, figsize=(15, 4)) diff --git a/examples/fourier_transform.py b/examples/fourier_transform.py index 8e99969c..2589ef12 100644 --- a/examples/fourier_transform.py +++ b/examples/fourier_transform.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - G = pg.graphs.Sensor(seed=42) G.compute_fourier_basis() diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py index 8ea42608..e0e0ac8d 100644 --- a/examples/heat_diffusion.py +++ b/examples/heat_diffusion.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() diff --git a/examples/kernel_localization.py b/examples/kernel_localization.py index 5bbd2225..d9484fa9 100644 --- a/examples/kernel_localization.py +++ b/examples/kernel_localization.py @@ -13,10 +13,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - fig, axes = plt.subplots(2, 4, figsize=(10, 4)) graphs = [ diff --git a/examples/random_walk.py b/examples/random_walk.py index b3031b4c..47e67dd3 100644 --- a/examples/random_walk.py +++ b/examples/random_walk.py @@ -13,10 +13,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - N = 7 steps = [0, 1, 2, 3] diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py index 2abf71d9..fc6f8766 100644 --- a/examples/wave_propagation.py +++ b/examples/wave_propagation.py @@ -12,10 +12,6 @@ from matplotlib import pyplot as plt import pygsp as pg -#plt.rc('font', family='Latin Modern Roman') -plt.rc('text', usetex=True) -plt.rc('text.latex', preamble=r'\usepackage{lmodern}') - n_side = 13 G = pg.graphs.Grid2d(n_side) G.compute_fourier_basis() From 5cafa9369c81ab6f13f0978424442ef0c1463a42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 02:26:55 +0100 Subject: [PATCH 341/365] replace f-strings (>=3.6) and @ infix operator (>=3.5) --- examples/heat_diffusion.py | 6 +++--- examples/random_walk.py | 6 +++--- examples/wave_propagation.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/heat_diffusion.py b/examples/heat_diffusion.py index e0e0ac8d..81701bbe 100644 --- a/examples/heat_diffusion.py +++ b/examples/heat_diffusion.py @@ -28,7 +28,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Heat(G, scale=t) - title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +36,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() diff --git a/examples/random_walk.py b/examples/random_walk.py index 47e67dd3..9fc69456 100644 --- a/examples/random_walk.py +++ b/examples/random_walk.py @@ -20,11 +20,11 @@ delta = np.zeros(graph.N) delta[N//2*N + N//2] = 1 -probability = sparse.diags(graph.dw**(-1)) @ graph.W +probability = sparse.diags(graph.dw**(-1)).dot(graph.W) fig, axes = plt.subplots(1, len(steps), figsize=(12, 3)) for step, ax in zip(steps, axes): - state = delta @ probability**step + state = (probability**step).__rmatmul__(delta) ## = delta @ probability**step graph.plot(state, ax=ax, title=r'$\delta P^{}$'.format(step)) ax.set_axis_off() @@ -47,7 +47,7 @@ if not hasattr(graph, 'coords'): graph.set_coordinates(seed=10) - P = sparse.diags(graph.dw**(-1)) @ graph.W + P = sparse.diags(graph.dw**(-1)).dot(graph.W) # e, u = np.linalg.eig(P.T.toarray()) # np.testing.assert_allclose(np.linalg.inv(u.T) @ np.diag(e) @ u.T, diff --git a/examples/wave_propagation.py b/examples/wave_propagation.py index fc6f8766..ec69b73c 100644 --- a/examples/wave_propagation.py +++ b/examples/wave_propagation.py @@ -28,7 +28,7 @@ fig, axes = plt.subplots(2, len(times), figsize=(12, 5)) for i, t in enumerate(times): g = pg.filters.Wave(G, time=t, speed=1) - title = fr'$\hat{{f}}({t}) = g_{{1,{t}}} \odot \hat{{f}}(0)$' + title = r'$\hat{{f}}({0}) = g_{{1,{0}}} \odot \hat{{f}}(0)$'.format(t) g.plot(alpha=1, ax=axes[0, i], title=title) axes[0, i].set_xlabel(r'$\lambda$') # axes[0, i].set_ylabel(r'$g(\lambda)$') @@ -36,9 +36,9 @@ axes[0, i].set_ylabel('') y = g.filter(x) line, = axes[0, i].plot(G.e, G.gft(y)) - labels = [fr'$\hat{{f}}({t})$', fr'$g_{{1,{t}}}$'] + labels = [r'$\hat{{f}}({})$'.format(t), r'$g_{{1,{}}}$'.format(t)] axes[0, i].legend([line, axes[0, i].lines[-3]], labels, loc='lower right') - G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=fr'$f({t})$') + G.plot(y, edges=False, highlight=sources, ax=axes[1, i], title=r'$f({})$'.format(t)) axes[1, i].set_aspect('equal', 'box') axes[1, i].set_axis_off() From 60666842c4a81e90265673da7667c0c10b844e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 29 Mar 2019 03:11:19 +0100 Subject: [PATCH 342/365] =?UTF-8?q?ring=20example:=20check=20that=20DFT=20?= =?UTF-8?q?is=20an=20eigenbasis=20(by=20Nathana=C3=ABl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/eigenvalue_concentration.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py index d9fcdd86..e47c78cf 100644 --- a/examples/eigenvalue_concentration.py +++ b/examples/eigenvalue_concentration.py @@ -6,6 +6,7 @@ graph becomes full. """ +import numpy as np from matplotlib import pyplot as plt import pygsp as pg @@ -13,6 +14,7 @@ fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) for k, ax in zip(n_neighbors, axes.T): + graph = pg.graphs.Ring(17, k=k) graph.compute_fourier_basis() graph.plot(graph.U[:, 1], ax=ax[0]) @@ -23,4 +25,14 @@ graph.set_coordinates('line1D') graph.plot(graph.U[:, :4], ax=ax[3], title='') + # Check that the DFT matrix is an eigenbasis of the Laplacian. + U = np.fft.fft(np.identity(graph.n_vertices)) + LambdaM = (graph.L.todense().dot(U)) / (U + 1e-15) + # Eigenvalues should be real. + assert np.all(np.abs(np.imag(LambdaM)) < 1e-10) + LambdaM = np.real(LambdaM) + # Check that the eigenvectors are really eigenvectors of the laplacian. + Lambda = np.mean(LambdaM, axis=0) + assert np.all(np.abs(LambdaM - Lambda) < 1e-10) + fig.tight_layout() From cd084527d3c82fe2f9a3268b5017690b59e3e2c9 Mon Sep 17 00:00:00 2001 From: nperraud <6399466+nperraud@users.noreply.github.com> Date: Fri, 29 Mar 2019 11:42:19 +0100 Subject: [PATCH 343/365] Remove eigenvectors from concentration demo --- examples/eigenvalue_concentration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/eigenvalue_concentration.py b/examples/eigenvalue_concentration.py index e47c78cf..c372bf2a 100644 --- a/examples/eigenvalue_concentration.py +++ b/examples/eigenvalue_concentration.py @@ -11,7 +11,7 @@ import pygsp as pg n_neighbors = [1, 2, 5, 8] -fig, axes = plt.subplots(4, len(n_neighbors), figsize=(15, 10)) +fig, axes = plt.subplots(3, len(n_neighbors), figsize=(15, 8)) for k, ax in zip(n_neighbors, axes.T): @@ -22,8 +22,8 @@ ax[1].spy(graph.W) ax[2].plot(graph.e, '.') ax[2].set_title('k={}'.format(k)) - graph.set_coordinates('line1D') - graph.plot(graph.U[:, :4], ax=ax[3], title='') + #graph.set_coordinates('line1D') + #graph.plot(graph.U[:, :4], ax=ax[3], title='') # Check that the DFT matrix is an eigenbasis of the Laplacian. U = np.fft.fft(np.identity(graph.n_vertices)) From 52b686b09c99f057ba4df4f3621e206510bbbef3 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 11:33:23 +0200 Subject: [PATCH 344/365] Add diagonals to grid2d --- pygsp/graphs/grid2d.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 81ae41a7..956ed5c2 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,6 +19,8 @@ class Grid2d(Graph): Number of vertices along the first dimension. N2 : int Number of vertices along the second dimension. Default is ``N1``. + diag_value : float + Value of the diagnal edges. Default is ``0.0`` See Also -------- @@ -36,7 +38,7 @@ class Grid2d(Graph): """ - def __init__(self, N1=16, N2=None, diagonal=0.0, **kwargs): + def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): if N2 is None: N2 = N1 @@ -52,8 +54,14 @@ def __init__(self, N1=16, N2=None, diagonal=0.0, **kwargs): diag_1[(N2 - 1)::N2] = 0 diag_2 = np.ones(N - N2) - W = sparse.diags(diagonals=[diag_1, diag_2], - offsets=[-1, -N2], + # Connecting node with they diagonal neighbours + diag_3 = np.full(N - N2 - 1, diag_value) + diag_4 = np.full(N - 2, diag_value) + diag_3[N2 - 1::N2] = 0 + diag_4[0::N2] = 0 + + W = sparse.diags(diagonals=[diag_1, diag_2, diag_3, diag_4], + offsets=[-1, -N2, -N2 - 1, -N2 + 1], shape=(N, N), format='csr', dtype='float') From 30d9156cf03f7195c0ffe93cf81ebc391bcae10f Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 11:45:44 +0200 Subject: [PATCH 345/365] Add test for the diagonal values --- pygsp/tests/test_graphs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 54ee93f2..08a1ae11 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -588,6 +588,16 @@ def test_imgpatches(self): def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) + def test_grid2d_diagonals(self): + value = 0.5 + G = graphs.Grid2d(6, 7, diag_value=value) + self.assertEqual(G.W[2, 8], value) + self.assertEqual(G.W[9, 1], value) + self.assertEqual(G.W[9, 3], value) + self.assertEqual(G.W[2, 14], 0.0) + self.assertEqual(G.W[17, 1], 0.0) + self.assertEqual(G.W[9, 16], 1.0) + self.assertEqual(G.W[20, 27], 1.0) suite_graphs = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 877756a354d6456d4139adcda67d6086f9b35c56 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 12:38:43 +0200 Subject: [PATCH 346/365] fix bug with small graphs --- pygsp/graphs/grid2d.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 956ed5c2..c4d83642 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -54,22 +54,16 @@ def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): diag_1[(N2 - 1)::N2] = 0 diag_2 = np.ones(N - N2) - # Connecting node with they diagonal neighbours - diag_3 = np.full(N - N2 - 1, diag_value) - diag_4 = np.full(N - 2, diag_value) - diag_3[N2 - 1::N2] = 0 - diag_4[0::N2] = 0 - - W = sparse.diags(diagonals=[diag_1, diag_2, diag_3, diag_4], - offsets=[-1, -N2, -N2 - 1, -N2 + 1], + W = sparse.diags(diagonals=[diag_1, diag_2], + offsets=[-1, -N2], shape=(N, N), format='csr', dtype='float') - if min(N1, N2) > 1 and diagonal != 0.0: + if min(N1, N2) > 1 and diag_value != 0.0: # Connecting node with they diagonal neighbours - diag_3 = np.full(N - N2 - 1, diagonal) - diag_4 = np.full(N - N2 + 1, diagonal) + diag_3 = np.full(N - N2 - 1, diag_value) + diag_4 = np.full(N - N2 + 1, diag_value) diag_3[N2 - 1::N2] = 0 diag_4[0::N2] = 0 D = sparse.diags(diagonals=[diag_3, diag_4], From 3e482f19f7f730a7174037d5cdf42897337bae9e Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 15:00:04 +0200 Subject: [PATCH 347/365] renaming --- pygsp/graphs/grid2d.py | 8 ++++---- pygsp/tests/test_graphs.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index c4d83642..b2f23c70 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -38,7 +38,7 @@ class Grid2d(Graph): """ - def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): + def __init__(self, N1=16, N2=None, diagonal=0.0, **kwargs): if N2 is None: N2 = N1 @@ -60,10 +60,10 @@ def __init__(self, N1=16, N2=None, diag_value=0.0, **kwargs): format='csr', dtype='float') - if min(N1, N2) > 1 and diag_value != 0.0: + if min(N1, N2) > 1 and diagonal != 0.0: # Connecting node with they diagonal neighbours - diag_3 = np.full(N - N2 - 1, diag_value) - diag_4 = np.full(N - N2 + 1, diag_value) + diag_3 = np.full(N - N2 - 1, diagonal) + diag_4 = np.full(N - N2 + 1, diagonal) diag_3[N2 - 1::N2] = 0 diag_4[0::N2] = 0 D = sparse.diags(diagonals=[diag_3, diag_4], diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 08a1ae11..19c1220d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -590,7 +590,7 @@ def test_grid2dimgpatches(self): def test_grid2d_diagonals(self): value = 0.5 - G = graphs.Grid2d(6, 7, diag_value=value) + G = graphs.Grid2d(6, 7, diagonal=value) self.assertEqual(G.W[2, 8], value) self.assertEqual(G.W[9, 1], value) self.assertEqual(G.W[9, 3], value) From 7fafee0f96978644e75c059ab4376ebe11a3dc62 Mon Sep 17 00:00:00 2001 From: Charles Gallay Date: Mon, 8 Apr 2019 15:00:50 +0200 Subject: [PATCH 348/365] rename doc --- pygsp/graphs/grid2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index b2f23c70..26bef79b 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,7 +19,7 @@ class Grid2d(Graph): Number of vertices along the first dimension. N2 : int Number of vertices along the second dimension. Default is ``N1``. - diag_value : float + diagonal : float Value of the diagnal edges. Default is ``0.0`` See Also From 015ec85263620c9c4f7145596006e9750cbaf0ac Mon Sep 17 00:00:00 2001 From: Rodrigo Pena Date: Tue, 11 Jun 2019 16:38:54 +0200 Subject: [PATCH 349/365] Update _layout.py --- pygsp/graphs/_layout.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/_layout.py b/pygsp/graphs/_layout.py index b20fcf89..31127fd1 100644 --- a/pygsp/graphs/_layout.py +++ b/pygsp/graphs/_layout.py @@ -92,10 +92,10 @@ def set_coordinates(self, kind='spring', **kwargs): self.coords[i] = self.info['com_coords'][comm_idx] + \ comm_rad * self.coords[i] elif kind == 'laplacian_eigenmap2D': - self.compute_fourier_basis(n_eigenvectors=2) + self.compute_fourier_basis(n_eigenvectors=3) self.coords = self.U[:, 1:3] elif kind == 'laplacian_eigenmap3D': - self.compute_fourier_basis(n_eigenvectors=3) + self.compute_fourier_basis(n_eigenvectors=4) self.coords = self.U[:, 1:4] else: raise ValueError('Unexpected argument kind={}.'.format(kind)) From 6568611c2be75eb937ada3a43b3a2bed2e7d4e3c Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 8 Jul 2019 18:47:57 +0200 Subject: [PATCH 350/365] icosahedron similar to jiang data --- pygsp/graphs/nngraphs/sphereicosahedron.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index a2553f15..4d00edda 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +import log import numpy as np from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 @@ -315,7 +316,7 @@ def _float_to_int(self, data, digits=None, dtype=np.int32): if digits is None: digits = self._decimal_to_digits(1e-8) elif isinstance(digits, float) or isinstance(digits, np.float): - digits = _decimal_to_digits(digits) + digits = self._decimal_to_digits(digits) elif not (isinstance(digits, int) or isinstance(digits, np.integer)): log.warn('Digits were passed as %s!', digits.__class__.__name__) raise ValueError('Digits must be None, int, or float!') From 0afe964983550e9d5a77ca1ea2207014c8827c76 Mon Sep 17 00:00:00 2001 From: droxef Date: Fri, 26 Jul 2019 12:07:03 +0200 Subject: [PATCH 351/365] problem rebase --- pygsp/graphs/nngraphs/nngraph.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 24cdcf82..68dd6093 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -492,12 +492,8 @@ def __init__(self, features, standardize=False, self.radius = radius self.kernel_width = kernel_width -<<<<<<< HEAD - super(NNGraph, self).__init__(W=W, coords=features, **params_graph) -======= super(NNGraph, self).__init__(W, plotting=plotting, coords=Xout, **kwargs) ->>>>>>> improve graph construction from adjacency def _get_extra_repr(self): attrs = { From 3cf57f0edecfbb242fd17c99e91c78f60ae12217 Mon Sep 17 00:00:00 2001 From: droxef Date: Fri, 26 Jul 2019 13:53:42 +0200 Subject: [PATCH 352/365] problem rebase 2 --- pygsp/graphs/nngraphs/sphereicosahedron.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index 4d00edda..3143690e 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -import log import numpy as np from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 @@ -318,7 +317,7 @@ def _float_to_int(self, data, digits=None, dtype=np.int32): elif isinstance(digits, float) or isinstance(digits, np.float): digits = self._decimal_to_digits(digits) elif not (isinstance(digits, int) or isinstance(digits, np.integer)): - log.warn('Digits were passed as %s!', digits.__class__.__name__) + # log.warn('Digits were passed as %s!', digits.__class__.__name__) raise ValueError('Digits must be None, int, or float!') # data is float so convert to large integers From d2fc4d744079409c4b1e1f012305daa419122db0 Mon Sep 17 00:00:00 2001 From: droxef Date: Fri, 26 Jul 2019 14:17:30 +0200 Subject: [PATCH 353/365] problem equiangular with Driscoll-Heally sampling: temp fix --- pygsp/graphs/fourier.py | 4 ---- pygsp/graphs/graph.py | 1 + pygsp/graphs/sphereequiangular.py | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 394008b3..3c215273 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -85,11 +85,7 @@ def coherence(self): >>> graph.compute_fourier_basis() >>> minimum = 1 / np.sqrt(graph.n_vertices) >>> print('{:.2f} in [{:.2f}, 1]'.format(graph.coherence, minimum)) -<<<<<<< HEAD - 0.91 in [0.12, 1] -======= 0.75 in [0.12, 1] ->>>>>>> doc: update Fourier coherence >>> >>> # Plot the most localized eigenvector. >>> import matplotlib.pyplot as plt diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index db393ebe..18b0fa95 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -2,6 +2,7 @@ from __future__ import division +import os from collections import Counter import numpy as np diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index 6bade6d8..b68bf359 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -177,7 +177,7 @@ def east(x): kernel_width = np.mean(distances) # weights = np.exp(-distances / (2 * kernel_width)) - weights = 1/distances + weights = 1/(distances+1e-8) W = sparse.csr_matrix( (weights, (row_index, col_index)), shape=(self.npix, self.npix), dtype=np.float32) From b7ceedc78e0f5ccf9799173c2c9cd014c0a052e3 Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 29 Jul 2019 11:59:20 +0200 Subject: [PATCH 354/365] correct equirectangular with different bandwidths --- pygsp/graphs/sphereequiangular.py | 49 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index b68bf359..e05cc452 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -22,7 +22,7 @@ class SphereEquiangular(Graph): Parameters ---------- bw : int or list or tuple - Resolution of the sampling scheme, corresponding to the bandwidth. + Resolution of the sampling scheme, corresponding to the bandwidth (latitude, latitude). Use a list or tuple to have a different resolution for latitude and longitude (default = 64) sptype : string sampling type (default = 'SOFT') @@ -47,7 +47,7 @@ class SphereEquiangular(Graph): >>> _ = _ = G.plot(ax=ax2) """ - def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwargs): + def __init__(self, bw=64, sptype='DH', dist='euclidean', **kwargs): if isinstance(bw, int): bw = (bw, bw) elif len(bw)>2: @@ -66,10 +66,10 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwarg elif sptype is 'SOFT': # SO(3) Fourier Transform optimal beta = np.pi * (2 * np.arange(2 * bw[0]) + 1) / (4. * bw[0]) alpha = np.arange(2 * bw[1]) * np.pi / bw[1] - elif sptype == 'CC': # Clenshaw-Curtis + elif sptype == 'CC': # Clenshaw-Curtis # TODO: no warranty for good weight matrix beta = np.linspace(0, np.pi, 2 * bw[0] + 1) alpha = np.linspace(0, 2 * np.pi, 2 * bw[1] + 2, endpoint=False) - elif sptype == 'GL': # Gauss-legendre + elif sptype == 'GL': # Gauss-legendre # TODO: no warranty for good weight matrix try: from numpy.polynomial.legendre import leggauss except: @@ -78,7 +78,7 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwarg x, _ = leggauss(bw[0] + 1) # TODO: leggauss docs state that this may not be only stable for orders > 100 beta = np.arccos(x) alpha = np.arange(2 * bw[1] + 2) * np.pi / (bw[1] + 1) - if sptype == 'OD': # Optimal Dimensionality + if sptype == 'OD': # Optimal Dimensionality # TODO: move in other file theta, phi = np.zeros(4*bw[0]**2), np.zeros(4*bw[0]**2) index=0 #beta = np.pi * (2 * np.arange(2 * bw) + 1) / (4. * bw) @@ -92,12 +92,8 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwarg else: theta, phi = np.meshgrid(*(beta, alpha),indexing='ij') self.lat, self.lon = theta.shape - if cylinder: - ct = theta.flatten() * 2 * bw[1] / np.pi - st = 1 - else: - ct = np.cos(theta).flatten() - st = np.sin(theta).flatten() + ct = np.cos(theta).flatten() + st = np.sin(theta).flatten() cp = np.cos(phi).flatten() sp = np.sin(phi).flatten() x = st * cp @@ -109,25 +105,25 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', cylinder=False, **kwarg ## neighbors and weight matrix calculation def south(x): - if x >= self.npix - self.lat: - return (x + self.lat//2)%self.lat + self.npix - self.lat + if x >= self.npix - self.lon: + return (x + self.lon//2)%self.lon + self.npix - self.lon return x + self.lon def north(x): - if x < self.lat: - return (x + self.lat//2)%self.lat + if x < self.lon: + return (x + self.lon//2)%self.lon return x - self.lon def west(x): if x%(self.lon)<1: try: - assert x//self.lat == (x-1+self.lon)//self.lat + assert x//self.lon == (x-1+self.lon)//self.lon except: raise x += self.lon else: try: - assert x//self.lat == (x-1)//self.lat + assert x//self.lon == (x-1)//self.lon except: raise return x - 1 @@ -135,13 +131,13 @@ def west(x): def east(x): if x%(self.lon)>=self.lon-1: try: - assert x//self.lat == (x+1-self.lon)//self.lat + assert x//self.lon == (x+1-self.lon)//self.lon except: raise x -= self.lon else: try: - assert x//self.lat == (x+1)//self.lat + assert x//self.lon == (x+1)//self.lon except: raise return x + 1 @@ -152,6 +148,8 @@ def east(x): # neighbor = [south(west(ind)), west(ind), north(west(ind)), north(ind), # north(east(ind)), east(ind), south(east(ind)), south(ind)] # elif neighbors==4: + # if self.sptype == 'DH' and x < self.lon: + # neighbor = [] neighbor = [west(ind), north(ind), east(ind), south(ind)] # else: # neighbor = [] @@ -177,7 +175,7 @@ def east(x): kernel_width = np.mean(distances) # weights = np.exp(-distances / (2 * kernel_width)) - weights = 1/(distances+1e-8) + weights = 1/(distances+1e-8) # TODO: find a better representation for sampling 'Driscoll-Heally' W = sparse.csr_matrix( (weights, (row_index, col_index)), shape=(self.npix, self.npix), dtype=np.float32) @@ -186,3 +184,14 @@ def east(x): super(SphereEquiangular, self).__init__(adjacency=W, coords=coords, plotting=plotting, **kwargs) + +if __name__=='__main__': + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d import Axes3D + G = SphereEquiangular(bw=(4, 8), sptype='SOFT') # (384, 576) + fig = plt.figure() + ax1 = fig.add_subplot(121) + ax2 = fig.add_subplot(122, projection='3d') + _ = ax1.spy(G.W, markersize=1.5) + _ = _ = G.plot(ax=ax2) + plt.show() From 02130b98be8c61149fbf20539ac16f3feccba69b Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 29 Jul 2019 13:53:02 +0200 Subject: [PATCH 355/365] add longitude and latitude coords --- pygsp/graphs/nngraphs/spherehealpix.py | 3 ++- pygsp/graphs/sphereequiangular.py | 33 +++++++++++++------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index de64fc69..7885158c 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -44,6 +44,7 @@ def __init__(self, Nside=1024, nest=True, **kwargs): npix = hp.nside2npix(Nside) indexes = np.arange(npix) x, y, z = hp.pix2vec(Nside, indexes, nest=nest) + self.lat, self.lon = hp.pix2ang(Nside, indexes, nest=nest, lonlat=False) coords = np.vstack([x, y, z]).transpose() coords = np.asarray(coords, dtype=np.float32) ## TODO: n_neighbors in function of Nside @@ -53,7 +54,7 @@ def __init__(self, Nside=1024, nest=True, **kwargs): try: sigma = opt_std[Nside] except: - sigma = 0.001 + raise ValueError('Unknown sigma for nside>32') plotting = { 'vertex_size': 80, diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index e05cc452..4260a113 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -91,7 +91,8 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', **kwargs): index += end else: theta, phi = np.meshgrid(*(beta, alpha),indexing='ij') - self.lat, self.lon = theta.shape + self.lat, self.lon = theta, phi + self.bwlat, self.bwlon = theta.shape ct = np.cos(theta).flatten() st = np.sin(theta).flatten() cp = np.cos(phi).flatten() @@ -105,39 +106,39 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', **kwargs): ## neighbors and weight matrix calculation def south(x): - if x >= self.npix - self.lon: - return (x + self.lon//2)%self.lon + self.npix - self.lon - return x + self.lon + if x >= self.npix - self.bwlon: + return (x + self.bwlon//2)%self.bwlon + self.npix - self.bwlon + return x + self.bwlon def north(x): - if x < self.lon: - return (x + self.lon//2)%self.lon - return x - self.lon + if x < self.bwlon: + return (x + self.bwlon//2)%self.bwlon + return x - self.bwlon def west(x): - if x%(self.lon)<1: + if x%(self.bwlon)<1: try: - assert x//self.lon == (x-1+self.lon)//self.lon + assert x//self.bwlon == (x-1+self.bwlon)//self.bwlon except: raise - x += self.lon + x += self.bwlon else: try: - assert x//self.lon == (x-1)//self.lon + assert x//self.bwlon == (x-1)//self.bwlon except: raise return x - 1 def east(x): - if x%(self.lon)>=self.lon-1: + if x%(self.bwlon)>=self.bwlon-1: try: - assert x//self.lon == (x+1-self.lon)//self.lon + assert x//self.bwlon == (x+1-self.bwlon)//self.bwlon except: raise - x -= self.lon + x -= self.bwlon else: try: - assert x//self.lon == (x+1)//self.lon + assert x//self.bwlon == (x+1)//self.bwlon except: raise return x + 1 @@ -188,7 +189,7 @@ def east(x): if __name__=='__main__': import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D - G = SphereEquiangular(bw=(4, 8), sptype='SOFT') # (384, 576) + G = SphereEquiangular(bw=(4, 8), sptype='DH') # (384, 576) fig = plt.figure() ax1 = fig.add_subplot(121) ax2 = fig.add_subplot(122, projection='3d') From 9707b08e4b8bc3d0fdad523bc31a2d984876f15d Mon Sep 17 00:00:00 2001 From: droxef Date: Tue, 30 Jul 2019 17:42:35 +0200 Subject: [PATCH 356/365] correct some docs and move OD sphere graph --- pygsp/graphs/nngraphs/sphere.py | 71 +++++++++++ pygsp/graphs/nngraphs/spherehealpix.py | 18 ++- pygsp/graphs/nngraphs/sphereicosahedron.py | 8 +- pygsp/graphs/sphereequiangular.py | 140 ++++++++++++--------- 4 files changed, 173 insertions(+), 64 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 1262b922..b38c2d31 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -75,3 +75,74 @@ def _get_extra_repr(self): 'seed': self.seed} attrs.update(super(Sphere, self)._get_extra_repr()) return attrs + + +class SphereOptimalDimensionality(NNGraph): + r"""Spherical-shaped graph using optimal dimensionality sampling scheme (NN-graph). + + Parameters + ---------- + bandwidth : int + Resolution of the sampling scheme, corresponding to the number of latitude rings (default = 64) + distance_type : {'euclidean', 'geodesic'} + type of distance use to compute edge weights (default = 'euclidean') + + See Also + -------- + SphereEquiangular, SphereHealpix, SphereIcosahedron + + Notes + ------ + The optimal dimensionality[1]_ sampling scheme consists on `\mathtt{bandwidth}` latitude rings equispaced. + The number of longitude pixels is different for each rings, and correspond to the number of spherical harmonics \ + for each mode. + The number of pixels is then only `2*\mathtt{bandwidth}` + + References + ---------- + [1] Z. Khalid, R. A. Kennedy, et J. D. McEwen, « An Optimal-Dimensionality Sampling Scheme + on the Sphere with Fast Spherical Harmonic Transforms », IEEE Transactions on Signal Processing, + vol. 62, no. 17, pp. 4597‑4610, Sept. 2014. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> G = graphs.SphereOptimalDimensionality(bandwidth=8) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1.5) + >>> _ = _ = G.plot(ax=ax2) + + """ + def __init__(self, bandwidth=64, distance_type='euclidean', **kwargs): + self.bandwidth = bandwidth + if distance_type not in ['geodesic', 'euclidean']: + raise ValueError('Unknown distance type value:' + distance_type) + + ## sampling and coordinates calculation + theta, phi = np.zeros(4*bandwidth**2), np.zeros(4*bandwidth**2) + index=0 + beta = np.pi * ((np.arange(2 * bandwidth + 1)%2)*(4*bandwidth-1)+np.arange(2 * bandwidth + 1)*- + 1**(np.arange(2 * bandwidth + 1)%2)) / (4 * bandwidth - 1) + for i in range(2*bandwidth): + alpha = 2 * np.pi * np.arange(2 * i + 1) / (2 * i + 1) + end = len(alpha) + theta[index:index+end], phi[index:index+end] = np.repeat(beta[i], end), alpha + index += end + self.lat, self.lon = theta-np.pi/2, phi + self.bwlat, self.bwlon = theta.shape + ct = np.cos(theta).flatten() + st = np.sin(theta).flatten() + cp = np.cos(phi).flatten() + sp = np.sin(phi).flatten() + x = st * cp + y = st * sp + z = ct + coords = np.vstack([x, y, z]).T + coords = np.asarray(coords, dtype=np.float32) + self.npix = len(coords) + + plotting = {"limits": np.array([-1, 1, -1, 1, -1, 1])} + super(SphereOptimalDimensionality, self).__init__(coords, k=4, center=False, rescale=False, + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index 7885158c..7562836f 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -16,7 +16,7 @@ def _import_hp(): class SphereHealpix(NNGraph): - r"""Spherical-shaped graph using HEALPix sampling scheme [https://healpix.jpl.nasa.gov/] (NN-graph). + r"""Spherical-shaped graph using HEALPix sampling scheme (NN-graph). Parameters ---------- @@ -25,6 +25,20 @@ class SphereHealpix(NNGraph): nest : bool ordering of the pixels (default = True) + See Also + -------- + SphereEquiangular, SphereIcosahedron + + Notes + ----- + This graph us based on the HEALPix[1]_ sampling scheme mainly used by the cosmologist. + Heat Kernel Distance is used to find its weight matrix. + + References + ---------- + [1] K. M. Gorski et al., « HEALPix -- a Framework for High Resolution Discretization, + and Fast Analysis of Data Distributed on the Sphere », ApJ, vol. 622, nᵒ 2, p. 759‑771, avr. 2005. + Examples -------- >>> import matplotlib.pyplot as plt @@ -60,5 +74,5 @@ def __init__(self, Nside=1024, nest=True, **kwargs): 'vertex_size': 80, "limits": np.array([-1, 1, -1, 1, -1, 1]) } - super(SphereHealpix, self).__init__(Xin=coords, k=n_neighbors, center=False, rescale=False, + super(SphereHealpix, self).__init__(coords, k=n_neighbors, center=False, rescale=False, sigma=sigma, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index 3143690e..e7ec8c7d 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -17,6 +17,10 @@ class SphereIcosahedron(NNGraph): sampling : string What the pixels represent. Either a vertex or a face (default = 'vertex') + See Also + -------- + SphereHealpix, SphereEquiangular + Examples -------- >>> import matplotlib.pyplot as plt @@ -29,7 +33,7 @@ class SphereIcosahedron(NNGraph): >>> _ = _ = G.plot(ax=ax2) """ - + # TODO create a new class for 'face' as it is the dual of icosahedron and the dodecahedron def __init__(self, level=5, sampling='vertex', **kwargs): if sampling not in ['vertex', 'face']: @@ -69,7 +73,7 @@ def __init__(self, level=5, sampling='vertex', **kwargs): self.nv_next = int((self.ne * 4) - (self.nf * 4) + 2) neighbours = 3 if 'face' in sampling else (5 if level == 0 else 6) - super(SphereIcosahedron, self).__init__(Xin=self.coords, k=neighbours, center=False, rescale=False, **kwargs) + super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, center=False, rescale=False, **kwargs) def divide(self): """ diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index 4260a113..c2944446 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- +import warnings import numpy as np from scipy import sparse -from pygsp.graphs import Graph # prevent circular import in Python < 3.5 +from pygsp.graphs import Graph # prevent circular import in Python < 3.5 def _import_hp(): @@ -16,30 +17,57 @@ def _import_hp(): 'Original exception: {}'.format(e)) return hp + class SphereEquiangular(Graph): r"""Spherical-shaped graph using equirectangular sampling scheme. Parameters ---------- - bw : int or list or tuple + bandwidth : int or list or tuple Resolution of the sampling scheme, corresponding to the bandwidth (latitude, latitude). Use a list or tuple to have a different resolution for latitude and longitude (default = 64) - sptype : string - sampling type (default = 'SOFT') - * DH original Driscoll-Healy - * SOFT equiangular without poles - * CC use of Clenshaw-Curtis quadrature - * GL use of Gauss-Legendre quadrature - * OD optimal dimensionality - dist : string - type of distance use to compute edge weights, euclidean or geodesic (default = 'euclidean') - cylinder : bool - adapt the grid on a cylinder + sampling : {'Driscoll-Heally', 'SOFT', 'Clenshaw-Curtis', 'Gauss-Legendre'} + sampling scheme (default = 'SOFT') + * Driscoll-Healy is the original sampling scheme of the sphere + * SOFT is an upgraded version without the poles + * Clenshaw-Curtis use the quadrature of its name to find the position of the latitude rings + * Gauss-Legendre use the quadrature of its name to find the position of the latitude rings + * Optimal Dimensionality guarranty the use of a minimum number of pixels, different for each latitude ring + distance_type : {'euclidean', 'geodesic'} + type of distance use to compute edge weights (default = 'euclidean') + + See Also + -------- + SphereHealpix, SphereIcosahedron + + Notes + ------ + Driscoll-Heally is the original sampling scheme of the sphere [1] + SOFT is an updated sampling scheme, without the poles[2] + Clenshaw-Curtis is [3] + Gauss-Legendre is [4] + The weight matrix is designed following [5]_ + + References + ---------- + [1] J. R. Driscoll et D. M. Healy, « Computing Fourier Transforms and Convolutions on the 2-Sphere », + Advances in Applied Mathematics, vol. 15, no. 2, pp. 202‑250, June 1994. + [2] D. M. Healy, D. N. Rockmore, P. J. Kostelec, et S. Moore, « FFTs for the 2-Sphere-Improvements + and Variations », Journal of Fourier Analysis and Applications, vol. 9, no. 4, pp. 341‑385, Jul. 2003. + [3] D. Hotta and M. Ujiie, ‘A nestable, multigrid-friendly grid on a sphere for global spectral models + based on Clenshaw-Curtis quadrature’, Q J R Meteorol Soc, vol. 144, no. 714, pp. 1382–1397, Jul. 2018. + [4] J. Keiner et D. Potts, « Fast evaluation of quadrature formulae on the sphere », + Math. Comp., vol. 77, no. 261, pp. 397‑419, Jan. 2008. + [5] P. Frossard and R. Khasanova, ‘Graph-Based Classification of Omnidirectional Images’, + in 2017 IEEE International Conference on Computer Vision Workshops (ICCVW), Venice, Italy, 2017, pp. 860–869. + [6] Z. Khalid, R. A. Kennedy, et J. D. McEwen, « An Optimal-Dimensionality Sampling Scheme + on the Sphere with Fast Spherical Harmonic Transforms », IEEE Transactions on Signal Processing, + vol. 62, no. 17, pp. 4597‑4610, Sept. 2014. Examples -------- >>> import matplotlib.pyplot as plt - >>> G = graphs.SphereEquiangular(bw=8, sptype='SOFT') + >>> G = graphs.SphereEquiangular(bandwidth=8, sampling='SOFT') >>> fig = plt.figure() >>> ax1 = fig.add_subplot(121) >>> ax2 = fig.add_subplot(122, projection='3d') @@ -47,51 +75,43 @@ class SphereEquiangular(Graph): >>> _ = _ = G.plot(ax=ax2) """ - def __init__(self, bw=64, sptype='DH', dist='euclidean', **kwargs): - if isinstance(bw, int): - bw = (bw, bw) - elif len(bw)>2: + # TODO add different plot to illustrate the different sampling schemes. Maybe zoom on some part of the sphere + # TODO move OD in different file, as well as the cylinder + def __init__(self, bandwidth=64, sampling='DH', distance_type='euclidean', **kwargs): + if isinstance(bandwidth, int): + bandwidth = (bandwidth, bandwidth) + elif len(bandwidth)>2: raise ValueError('Cannot have more than two bandwidths') - self.bw = bw - self.sptype = sptype - if sptype not in ['DH', 'SOFT', 'CC', 'GL', 'OD']: - raise ValueError('Unknown sampling type:' + sptype) - if dist not in ['geodesic', 'euclidean']: - raise ValueError('Unknown distance type value:' + dist) + self.bandwidth = bandwidth + self.sampling = sampling + if sampling not in ['Driscoll-Healy', 'SOFT', 'Clenshaw-Curtis', 'Gauss-Legendre']: + raise ValueError('Unknown sampling type:' + sampling) + if distance_type not in ['geodesic', 'euclidean']: + raise ValueError('Unknown distance type value:' + distance_type) ## sampling and coordinates calculation - if sptype is 'DH': - beta = np.arange(2 * bw[0]) * np.pi / (2. * bw[0]) # Driscoll-Heally - alpha = np.arange(2 * bw[1]) * np.pi / bw[1] - elif sptype is 'SOFT': # SO(3) Fourier Transform optimal - beta = np.pi * (2 * np.arange(2 * bw[0]) + 1) / (4. * bw[0]) - alpha = np.arange(2 * bw[1]) * np.pi / bw[1] - elif sptype == 'CC': # Clenshaw-Curtis # TODO: no warranty for good weight matrix - beta = np.linspace(0, np.pi, 2 * bw[0] + 1) - alpha = np.linspace(0, 2 * np.pi, 2 * bw[1] + 2, endpoint=False) - elif sptype == 'GL': # Gauss-legendre # TODO: no warranty for good weight matrix + if sampling is 'Driscoll-Healy': + beta = np.arange(2 * bandwidth[0]) * np.pi / (2. * bandwidth[0]) # Driscoll-Heally + alpha = np.arange(2 * bandwidth[1]) * np.pi / bandwidth[1] + elif sampling is 'SOFT': # SO(3) Fourier Transform optimal + beta = np.pi * (2 * np.arange(2 * bandwidth[0]) + 1) / (4. * bandwidth[0]) + alpha = np.arange(2 * bandwidth[1]) * np.pi / bandwidth[1] + elif sampling == 'Clenshaw-Curtis': # Clenshaw-Curtis + warnings.warn("The weight matrix may not be optimal for this sampling scheme as it was not tested.", UserWarning) + beta = np.linspace(0, np.pi, 2 * bandwidth[0] + 1) + alpha = np.linspace(0, 2 * np.pi, 2 * bandwidth[1] + 2, endpoint=False) + elif sampling == 'Gauss-Legendre': # Gauss-legendre + warnings.warn("The weight matrix may not be optimal for this sampling scheme as it was not tested.", UserWarning) try: from numpy.polynomial.legendre import leggauss except: raise ImportError("cannot import legendre quadrature from numpy." "Choose another sampling type or upgrade numpy.") - x, _ = leggauss(bw[0] + 1) # TODO: leggauss docs state that this may not be only stable for orders > 100 - beta = np.arccos(x) - alpha = np.arange(2 * bw[1] + 2) * np.pi / (bw[1] + 1) - if sptype == 'OD': # Optimal Dimensionality # TODO: move in other file - theta, phi = np.zeros(4*bw[0]**2), np.zeros(4*bw[0]**2) - index=0 - #beta = np.pi * (2 * np.arange(2 * bw) + 1) / (4. * bw) - beta = np.pi * ((np.arange(2 * bw[0] + 1)%2)*(4*bw[0]-1)+np.arange(2 * bw[0] + 1)*- - 1**(np.arange(2 * bw[0] + 1)%2)) / (4 * bw[0] - 1) - for i in range(2*bw[0]): - alpha = 2 * np.pi * np.arange(2 * i + 1) / (2 * i + 1) - end = len(alpha) - theta[index:index+end], phi[index:index+end] = np.repeat(beta[i], end), alpha - index += end - else: - theta, phi = np.meshgrid(*(beta, alpha),indexing='ij') - self.lat, self.lon = theta, phi + quad, _ = leggauss(bandwidth[0] + 1) # TODO: leggauss docs state that this may not be only stable for orders > 100 + beta = np.arccos(quad) + alpha = np.arange(2 * bandwidth[1] + 2) * np.pi / (bandwidth[1] + 1) + theta, phi = np.meshgrid(*(beta, alpha), indexing='ij') + self.lat, self.lon = theta-np.pi/2, phi self.bwlat, self.bwlon = theta.shape ct = np.cos(theta).flatten() st = np.sin(theta).flatten() @@ -107,7 +127,7 @@ def __init__(self, bw=64, sptype='DH', dist='euclidean', **kwargs): ## neighbors and weight matrix calculation def south(x): if x >= self.npix - self.bwlon: - return (x + self.bwlon//2)%self.bwlon + self.npix - self.bwlon + return (x + self.bwlon//2) % self.bwlon + self.npix - self.bwlon return x + self.bwlon def north(x): @@ -116,7 +136,7 @@ def north(x): return x - self.bwlon def west(x): - if x%(self.bwlon)<1: + if x % self.bwlon < 1: try: assert x//self.bwlon == (x-1+self.bwlon)//self.bwlon except: @@ -130,7 +150,7 @@ def west(x): return x - 1 def east(x): - if x%(self.bwlon)>=self.bwlon-1: + if x % self.bwlon >= self.bwlon-1: try: assert x//self.bwlon == (x+1-self.bwlon)//self.bwlon except: @@ -143,13 +163,13 @@ def east(x): raise return x + 1 - col_index=[] + col_index = [] for ind in range(self.npix): # if neighbors==8: # neighbor = [south(west(ind)), west(ind), north(west(ind)), north(ind), # north(east(ind)), east(ind), south(east(ind)), south(ind)] # elif neighbors==4: - # if self.sptype == 'DH' and x < self.lon: + # if self.sampling == 'DH' and x < self.lon: # neighbor = [] neighbor = [west(ind), north(ind), east(ind), south(ind)] # else: @@ -163,7 +183,7 @@ def east(x): col_index = col_index[keep] row_index = row_index[keep] - if dist=='geodesic': + if distance_type == 'geodesic': hp = _import_hp() distances = np.zeros(len(row_index)) for i, (pos1, pos2) in enumerate(zip(coords[row_index], coords[col_index])): @@ -173,7 +193,7 @@ def east(x): distances = np.sum((coords[row_index] - coords[col_index])**2, axis=1) # Compute similarities / edge weights. - kernel_width = np.mean(distances) + # kernel_width = np.mean(distances) # weights = np.exp(-distances / (2 * kernel_width)) weights = 1/(distances+1e-8) # TODO: find a better representation for sampling 'Driscoll-Heally' @@ -183,13 +203,13 @@ def east(x): plotting = {"limits": np.array([-1, 1, -1, 1, -1, 1])} super(SphereEquiangular, self).__init__(adjacency=W, coords=coords, - plotting=plotting, **kwargs) + plotting=plotting, **kwargs) if __name__=='__main__': import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D - G = SphereEquiangular(bw=(4, 8), sptype='DH') # (384, 576) + G = SphereEquiangular(bandwidth=(4, 8), sampling='DH') # (384, 576) fig = plt.figure() ax1 = fig.add_subplot(121) ax2 = fig.add_subplot(122, projection='3d') From 14ebb2275831914a9671ab7fe072df358c74f9a3 Mon Sep 17 00:00:00 2001 From: droxef Date: Tue, 30 Jul 2019 17:57:08 +0200 Subject: [PATCH 357/365] correct sampling type in equiangular graph --- pygsp/graphs/sphereequiangular.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index c2944446..4a82b2b1 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -90,10 +90,10 @@ def __init__(self, bandwidth=64, sampling='DH', distance_type='euclidean', **kwa raise ValueError('Unknown distance type value:' + distance_type) ## sampling and coordinates calculation - if sampling is 'Driscoll-Healy': + if sampling == 'Driscoll-Healy': beta = np.arange(2 * bandwidth[0]) * np.pi / (2. * bandwidth[0]) # Driscoll-Heally alpha = np.arange(2 * bandwidth[1]) * np.pi / bandwidth[1] - elif sampling is 'SOFT': # SO(3) Fourier Transform optimal + elif sampling == 'SOFT': # SO(3) Fourier Transform optimal beta = np.pi * (2 * np.arange(2 * bandwidth[0]) + 1) / (4. * bandwidth[0]) alpha = np.arange(2 * bandwidth[1]) * np.pi / bandwidth[1] elif sampling == 'Clenshaw-Curtis': # Clenshaw-Curtis @@ -209,7 +209,7 @@ def east(x): if __name__=='__main__': import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D - G = SphereEquiangular(bandwidth=(4, 8), sampling='DH') # (384, 576) + G = SphereEquiangular(bandwidth=(4, 8), sampling='Driscoll-Healy') # (384, 576) fig = plt.figure() ax1 = fig.add_subplot(121) ax2 = fig.add_subplot(122, projection='3d') From 02111fea51a93fb60c37525c61b08a331a173423 Mon Sep 17 00:00:00 2001 From: droxef Date: Wed, 31 Jul 2019 11:55:01 +0200 Subject: [PATCH 358/365] correct with new nngraph --- pygsp/graphs/nngraphs/sphere.py | 3 +-- pygsp/graphs/nngraphs/spherehealpix.py | 4 ++-- pygsp/graphs/nngraphs/sphereicosahedron.py | 3 ++- pygsp/graphs/sphereequiangular.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index b38c2d31..dead44ff 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -144,5 +144,4 @@ def __init__(self, bandwidth=64, distance_type='euclidean', **kwargs): self.npix = len(coords) plotting = {"limits": np.array([-1, 1, -1, 1, -1, 1])} - super(SphereOptimalDimensionality, self).__init__(coords, k=4, center=False, rescale=False, - plotting=plotting, **kwargs) + super(SphereOptimalDimensionality, self).__init__(coords, k=4, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index 7562836f..8e9319f6 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -52,6 +52,7 @@ class SphereHealpix(NNGraph): """ def __init__(self, Nside=1024, nest=True, **kwargs): + # TODO: add part of sphere construction hp = _import_hp() self.Nside = Nside self.nest = nest @@ -74,5 +75,4 @@ def __init__(self, Nside=1024, nest=True, **kwargs): 'vertex_size': 80, "limits": np.array([-1, 1, -1, 1, -1, 1]) } - super(SphereHealpix, self).__init__(coords, k=n_neighbors, center=False, rescale=False, - sigma=sigma, plotting=plotting, **kwargs) + super(SphereHealpix, self).__init__(coords, k=n_neighbors, kernel_width=2*sigma, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index e7ec8c7d..f020f4a6 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -72,8 +72,9 @@ def __init__(self, level=5, sampling='vertex', **kwargs): self.nv_prev = int((self.ne / 4) - (self.nf / 4) + 2) self.nv_next = int((self.ne * 4) - (self.nf * 4) + 2) + # change kind to 'radius', and add radius parameter. k will be ignored neighbours = 3 if 'face' in sampling else (5 if level == 0 else 6) - super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, center=False, rescale=False, **kwargs) + super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, **kwargs) def divide(self): """ diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index 4a82b2b1..8c58b56f 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -77,7 +77,7 @@ class SphereEquiangular(Graph): """ # TODO add different plot to illustrate the different sampling schemes. Maybe zoom on some part of the sphere # TODO move OD in different file, as well as the cylinder - def __init__(self, bandwidth=64, sampling='DH', distance_type='euclidean', **kwargs): + def __init__(self, bandwidth=64, sampling='SOFT', distance_type='euclidean', **kwargs): if isinstance(bandwidth, int): bandwidth = (bandwidth, bandwidth) elif len(bandwidth)>2: From 98ab35aeeb31ccdee32844362bec4e36586c1733 Mon Sep 17 00:00:00 2001 From: droxef Date: Wed, 31 Jul 2019 11:57:58 +0200 Subject: [PATCH 359/365] add plotting to sphere icosa --- pygsp/graphs/nngraphs/sphereicosahedron.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index f020f4a6..86a2fb00 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -72,9 +72,14 @@ def __init__(self, level=5, sampling='vertex', **kwargs): self.nv_prev = int((self.ne / 4) - (self.nf / 4) + 2) self.nv_next = int((self.ne * 4) - (self.nf * 4) + 2) + plotting = { + 'vertex_size': 80, + "limits": np.array([-1, 1, -1, 1, -1, 1]) + } + # change kind to 'radius', and add radius parameter. k will be ignored neighbours = 3 if 'face' in sampling else (5 if level == 0 else 6) - super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, **kwargs) + super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, plotting=plotting, **kwargs) def divide(self): """ From 3297cab1d4512c7f8e9a8a7e2a4ef6be529b6f8e Mon Sep 17 00:00:00 2001 From: droxef Date: Wed, 31 Jul 2019 12:09:06 +0200 Subject: [PATCH 360/365] rebase did not work correctly --- pygsp/graphs/nngraphs/nngraph.py | 30 +------------------------- pygsp/graphs/nngraphs/spherehealpix.py | 2 +- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 68dd6093..0f87d12c 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -149,26 +149,20 @@ def _nmslib(features, metric, order, kind, k, _, params): class NNGraph(Graph): r"""Nearest-neighbor graph. - The nearest-neighbor graph is built from a set of features, where the edge weight between vertices :math:`v_i` and :math:`v_j` is given by - .. math:: A(i,j) = k \left( \frac{d(v_i, v_j)}{\sigma} \right), - where :math:`d(v_i, v_j)` is a distance measure between some representation (the features) of :math:`v_i` and :math:`v_j`, :math:`k` is a kernel function that transforms a distance in :math:`[0, \infty]` to a similarity measure generally in :math:`[0, 1]`, and :math:`\sigma` is the kernel width. - For example, the features might be the 3D coordinates of points in a point cloud. Then, if ``metric='euclidean'`` and ``kernel='gaussian'`` (the defaults), :math:`A(i,j) = \exp(-\log(2) \| x_i - x_j \|_2^2 / \sigma^2)`, where :math:`x_i` is the 3D position of vertex :math:`v_i`. - The similarity matrix :math:`A` is sparsified by either keeping the ``k`` closest vertices for each vertex (if ``type='knn'``), or by setting to zero the similarity when the distance is greater than ``radius`` (if ``type='radius'``). - Parameters ---------- features : ndarray @@ -179,7 +173,6 @@ class NNGraph(Graph): and standard deviation of 1 (unit variance). metric : {'euclidean', 'manhattan', 'minkowski', 'max_dist'}, optional Metric used to compute pairwise distances. - * ``'euclidean'`` defines pairwise distances as :math:`d(v_i, v_j) = \| x_i - x_j \|_2`. * ``'manhattan'`` defines pairwise distances as @@ -190,7 +183,6 @@ class NNGraph(Graph): * ``'max_dist'`` defines pairwise distances as :math:`d(v_i, v_j) = \| x_i - x_j \|_\infty = \max(x_i - x_j)`, where the maximum is taken over the elements of the vector. - More metrics may be supported for some backends. Please refer to the documentation of the chosen backend. order : float, optional @@ -215,7 +207,6 @@ class NNGraph(Graph): kernel : string or function The function :math:`k` that transforms a distance to a similarity. The following kernels are pre-defined. - * ``'gaussian'`` defines the Gaussian, also known as the radial basis function (RBF), kernel :math:`k(d) = \exp(-\log(2) d^2)`. * ``'exponential'`` defines the kernel :math:`k(d) = \exp(-\log(2) d)`. @@ -224,7 +215,6 @@ class NNGraph(Graph): * Other kernels are ``'tricube'``, ``'triweight'``, ``'quartic'``, ``'epanechnikov'``, ``'logistic'``, and ``'sigmoid'``. See `Wikipedia `_. - Another option is to pass a function that takes a vector of pairwise distances and returns the similarities. All the predefined kernels return a similarity of 0.5 when the distance is one. @@ -251,7 +241,6 @@ class NNGraph(Graph): * ``'nmslib'`` uses the `Non-Metric Space Library (NMSLIB) `_. That method is an approximation. It should be the fastest in high-dimensional spaces. - You can look at this `benchmark `_ to get an idea of the relative performance of those backends. It's nonetheless wise to run @@ -259,12 +248,9 @@ class NNGraph(Graph): kwargs : dict Parameters to be passed to the :class:`Graph` constructor or the backend library. - Examples -------- - Construction of a graph from a set of features. - >>> import matplotlib.pyplot as plt >>> rs = np.random.RandomState(42) >>> features = rs.uniform(size=(30, 2)) @@ -272,9 +258,7 @@ class NNGraph(Graph): >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=5) >>> _ = G.plot(ax=axes[1]) - Radius versus knn graph. - >>> features = rs.uniform(size=(100, 3)) >>> fig, ax = plt.subplots() >>> G = graphs.NNGraph(features, kind='radius', radius=0.2964) @@ -285,9 +269,7 @@ class NNGraph(Graph): >>> _ = ax.hist(G.W.data, bins=20, label=label, alpha=0.5) >>> _ = ax.legend() >>> _ = ax.set_title('edge weights') - Control of the sparsity of knn and radius graphs. - >>> features = rs.uniform(size=(100, 3)) >>> n_edges = dict(knn=[], radius=[]) >>> n_neighbors = np.arange(1, 100, 5) @@ -305,9 +287,7 @@ class NNGraph(Graph): >>> _ = axes[0].set_xlabel('number of neighbors (knn graph)') >>> _ = axes[1].set_xlabel('radius (radius graph)') >>> _ = fig.suptitle('Sparsity') - Choice of metric and the curse of dimensionality. - >>> fig, axes = plt.subplots(1, 2) >>> for dim, ax in zip([3, 30], axes): ... features = rs.uniform(size=(100, dim)) @@ -317,9 +297,7 @@ class NNGraph(Graph): ... _ = ax.hist(G.W.data, bins=20, label=metric, alpha=0.5) ... _ = ax.legend() ... _ = ax.set_title('edge weights, {} dimensions'.format(dim)) - Choice of kernel. - >>> fig, axes = plt.subplots(1, 2) >>> width = 0.3 >>> distances = np.linspace(0, 1, 200) @@ -334,9 +312,7 @@ class NNGraph(Graph): ... _ = axes[1].hist(G.W.data, bins=20, label=kernel, alpha=0.5) >>> _ = axes[1].legend() >>> _ = axes[1].set_title('edge weights') - Choice of kernel width. - >>> fig, axes = plt.subplots() >>> for width in [.2, .3, .4, .6, .8, None]: ... G = graphs.NNGraph(features, kernel_width=width) @@ -344,9 +320,7 @@ class NNGraph(Graph): ... _ = axes.hist(G.W.data, bins=20, label=label, alpha=0.5) >>> _ = axes.legend(loc='upper left') >>> _ = axes.set_title('edge weights') - Choice of backend. Compare on your data! - >>> import time >>> sizes = [300, 1000, 3000] >>> dims = [3, 100] @@ -370,7 +344,6 @@ class NNGraph(Graph): ... _ = ax.set_xlabel('number of vertices') >>> _ = axes[0].set_ylabel('execution time [s]') >>> _ = axes[1].legend(loc='upper left') - """ def __init__(self, features, standardize=False, @@ -492,8 +465,7 @@ def __init__(self, features, standardize=False, self.radius = radius self.kernel_width = kernel_width - super(NNGraph, self).__init__(W, plotting=plotting, - coords=Xout, **kwargs) + super(NNGraph, self).__init__(W=W, coords=features, **params_graph) def _get_extra_repr(self): attrs = { diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index 8e9319f6..aed33d7c 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -75,4 +75,4 @@ def __init__(self, Nside=1024, nest=True, **kwargs): 'vertex_size': 80, "limits": np.array([-1, 1, -1, 1, -1, 1]) } - super(SphereHealpix, self).__init__(coords, k=n_neighbors, kernel_width=2*sigma, plotting=plotting, **kwargs) + super(SphereHealpix, self).__init__(coords, k=n_neighbors, kernel_width=np.sqrt(2*sigma), plotting=plotting, **kwargs) From 5a3a15200ac62214caa085a8b34d8ea0781702d4 Mon Sep 17 00:00:00 2001 From: droxef Date: Wed, 31 Jul 2019 13:58:23 +0200 Subject: [PATCH 361/365] change NNgraph --- pygsp/graphs/nngraphs/nngraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 0f87d12c..786dad73 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -465,7 +465,7 @@ def __init__(self, features, standardize=False, self.radius = radius self.kernel_width = kernel_width - super(NNGraph, self).__init__(W=W, coords=features, **params_graph) + super(NNGraph, self).__init__(W, coords=features, **params_graph) def _get_extra_repr(self): attrs = { From c65f6a05f766aed83499650bc0677e679f326562 Mon Sep 17 00:00:00 2001 From: droxef Date: Mon, 2 Sep 2019 15:59:14 +0200 Subject: [PATCH 362/365] equiangular doc and icosahdron graph clases changed --- pygsp/graphs/nngraphs/sphereicosahedron.py | 364 ++++++++++++++++++++- pygsp/graphs/sphereequiangular.py | 61 +++- 2 files changed, 411 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/nngraphs/sphereicosahedron.py b/pygsp/graphs/nngraphs/sphereicosahedron.py index 86a2fb00..403725cd 100644 --- a/pygsp/graphs/nngraphs/sphereicosahedron.py +++ b/pygsp/graphs/nngraphs/sphereicosahedron.py @@ -19,7 +19,12 @@ class SphereIcosahedron(NNGraph): See Also -------- - SphereHealpix, SphereEquiangular + SphereDodecahedron, SphereHealpix, SphereEquiangular + + Notes + ------ + The icosahedron is the dual of the dodecahedron. Thus the pixels in this graph represent either the vertices \ + of the icosahedron, or the faces of the dodecahedron. Examples -------- @@ -33,7 +38,6 @@ class SphereIcosahedron(NNGraph): >>> _ = _ = G.plot(ax=ax2) """ - # TODO create a new class for 'face' as it is the dual of icosahedron and the dodecahedron def __init__(self, level=5, sampling='vertex', **kwargs): if sampling not in ['vertex', 'face']: @@ -63,7 +67,7 @@ def __init__(self, level=5, sampling='vertex', **kwargs): if sampling=='face': self.coords = self.coords[self.faces].mean(axis=1) - self.lat, self.long = self.xyz2latlong() + self.lat, self.lon = self.xyz2latlong() self.npix = len(self.coords) self.nf = 20 * 4**self.level @@ -357,3 +361,357 @@ def _decimal_to_digits(self, decimal, min_digits=None): if min_digits is not None: digits = np.clip(digits, min_digits, 20) return digits + + +class SphereDodecahedron(NNGraph): + r"""Spherical-shaped graph based on the projection of the dodecahedron (NN-graph). + Code inspired by Max Jiang [https://github.com/maxjiang93/ugscnn/blob/master/meshcnn/mesh.py] + + Parameters + ---------- + level : int + Resolution of the sampling scheme, or how many times the faces are divided (default = 5) + sampling : string + What the pixels represent. Either a vertex or a face (default = 'vertex') + + See Also + -------- + SphereIcosahedron, SphereHealpix, SphereEquiangular + + Notes + ------ + The dodecahedron is the dual of the icosahedron. Thus the pixels in this graph represent either the vertices \ + of the dodecahedron, or the faces of the icosahedron. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> from mpl_toolkits.mplot3d import Axes3D + >>> G = graphs.SphereDodecahedron(level=1) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1.5) + >>> _ = _ = G.plot(ax=ax2) + + """ + def __init__(self, level=5, **kwargs): + + PHI = (1 + np.sqrt(5))/2 + radius = np.sqrt(PHI**2+1) + coords = [-1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0, + 0, -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, + PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, 1] + coords = np.reshape(coords, (-1,3)) + coords = coords/radius + faces = [0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, + 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, + 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, + 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1] + self.faces = np.reshape(faces, (20, 3)) + self.level = level + self.intp = None + + coords = self._upward(coords, self.faces) + self.coords = coords + + for i in range(level): + self.divide() + self.normalize() + + self.coords = self.coords[self.faces].mean(axis=1) + + self.lat, self.lon = self.xyz2latlong() + + self.npix = len(self.coords) + self.nf = 20 * 4**self.level + self.ne = 30 * 4**self.level + self.nv = self.ne - self.nf + 2 + self.nv_prev = int((self.ne / 4) - (self.nf / 4) + 2) + self.nv_next = int((self.ne * 4) - (self.nf * 4) + 2) + + plotting = { + 'vertex_size': 80, + "limits": np.array([-1, 1, -1, 1, -1, 1]) + } + + # change kind to 'radius', and add radius parameter. k will be ignored + neighbours = 3 + super(SphereIcosahedron, self).__init__(self.coords, k=neighbours, plotting=plotting, **kwargs) + + def divide(self): + """ + Subdivide a mesh into smaller triangles. + """ + faces = self.faces + vertices = self.coords + face_index = np.arange(len(faces)) + + # the (c,3) int set of vertex indices + faces = faces[face_index] + # the (c, 3, 3) float set of points in the triangles + triangles = vertices[faces] + # the 3 midpoints of each triangle edge vstacked to a (3*c, 3) float + src_idx = np.vstack([faces[:, g] for g in [[0, 1], [1, 2], [2, 0]]]) + mid = np.vstack([triangles[:, g, :].mean(axis=1) for g in [[0, 1], + [1, 2], + [2, 0]]]) + mid_idx = (np.arange(len(face_index) * 3)).reshape((3, -1)).T + # for adjacent faces we are going to be generating the same midpoint + # twice, so we handle it here by finding the unique vertices + unique, inverse = self._unique_rows(mid) + + mid = mid[unique] + src_idx = src_idx[unique] + mid_idx = inverse[mid_idx] + len(vertices) + # the new faces, with correct winding + f = np.column_stack([faces[:, 0], mid_idx[:, 0], mid_idx[:, 2], + mid_idx[:, 0], faces[:, 1], mid_idx[:, 1], + mid_idx[:, 2], mid_idx[:, 1], faces[:, 2], + mid_idx[:, 0], mid_idx[:, 1], mid_idx[:, 2], ]).reshape((-1, 3)) + # add the 3 new faces per old face + new_faces = np.vstack((faces, f[len(face_index):])) + # replace the old face with a smaller face + new_faces[face_index] = f[:len(face_index)] + + new_vertices = np.vstack((vertices, mid)) + # source ids + nv = vertices.shape[0] + identity_map = np.stack((np.arange(nv), np.arange(nv)), axis=1) + src_id = np.concatenate((identity_map, src_idx), axis=0) + + self.coords = new_vertices + self.faces = new_faces + self.intp = src_id + + def normalize(self, radius=1): + ''' + Reproject to spherical surface + ''' + vectors = self.coords + scalar = (vectors ** 2).sum(axis=1)**.5 + unit = vectors / scalar.reshape((-1, 1)) + offset = radius - scalar + self.coords += unit * offset.reshape((-1, 1)) + + def xyz2latlong(self): + x, y, z = self.coords[:, 0], self.coords[:, 1], self.coords[:, 2] + long = np.arctan2(y, x) + np.pi + xy2 = x**2 + y**2 + lat = np.arctan2(z, np.sqrt(xy2)) + return lat, long + + def _upward(self, V_ico, F_ico, ind=11): + V0 = V_ico[ind] + Z0 = np.array([0, 0, 1]) + k = np.cross(V0, Z0) + ct = np.dot(V0, Z0) + st = -np.linalg.norm(k) + R = self._rot_matrix(k, ct, st) + V_ico = V_ico.dot(R) + # rotate a neighbor to align with (+y) + ni = self._find_neighbor(F_ico, ind)[0] + vec = V_ico[ni].copy() + vec[2] = 0 + vec = vec/np.linalg.norm(vec) + y_ = np.eye(3)[1] + + k = np.eye(3)[2] + crs = np.cross(vec, y_) + ct = -np.dot(vec, y_) + st = -np.sign(crs[-1])*np.linalg.norm(crs) + R2 = self._rot_matrix(k, ct, st) + V_ico = V_ico.dot(R2) + return V_ico + + def _find_neighbor(self, F, ind): + """find a icosahedron neighbor of vertex i""" + FF = [F[i] for i in range(F.shape[0]) if ind in F[i]] + FF = np.concatenate(FF) + FF = np.unique(FF) + neigh = [f for f in FF if f != ind] + return neigh + + def _rot_matrix(self, rot_axis, cos_t, sin_t): + k = rot_axis / np.linalg.norm(rot_axis) + I = np.eye(3) + + R = [] + for i in range(3): + v = I[i] + vr = v*cos_t+np.cross(k, v)*sin_t+k*(k.dot(v))*(1-cos_t) + R.append(vr) + R = np.stack(R, axis=-1) + return R + + def _ico_rot_matrix(self, ind): + """ + return rotation matrix to perform permutation corresponding to + moving a certain icosahedron node to the top + """ + v0_ = self.v0.copy() + f0_ = self.f0.copy() + V0 = v0_[ind] + Z0 = np.array([0, 0, 1]) + + # rotate the point to the top (+z) + k = np.cross(V0, Z0) + ct = np.dot(V0, Z0) + st = -np.linalg.norm(k) + R = self._rot_matrix(k, ct, st) + v0_ = v0_.dot(R) + + # rotate a neighbor to align with (+y) + ni = self._find_neighbor(f0_, ind)[0] + vec = v0_[ni].copy() + vec[2] = 0 + vec = vec/np.linalg.norm(vec) + y_ = np.eye(3)[1] + + k = np.eye(3)[2] + crs = np.cross(vec, y_) + ct = np.dot(vec, y_) + st = -np.sign(crs[-1])*np.linalg.norm(crs) + + R2 = self._rot_matrix(k, ct, st) + return R.dot(R2) + + def _rotseq(self, V, acc=9): + """sequence to move an original node on icosahedron to top""" + seq = [] + for i in range(11): + Vr = V.dot(self._ico_rot_matrix(i)) + # lexsort + s1 = np.lexsort(np.round(V.T, acc)) + s2 = np.lexsort(np.round(Vr.T, acc)) + s = s1[np.argsort(s2)] + seq.append(s) + return tuple(seq) + + def _unique_rows(self, data, digits=None): + """ + Returns indices of unique rows. It will return the + first occurrence of a row that is duplicated: + [[1,2], [3,4], [1,2]] will return [0,1] + Parameters + --------- + data: (n,m) set of floating point data + digits: how many digits to consider for the purposes of uniqueness + Returns + -------- + unique: (j) array, index in data which is a unique row + inverse: (n) length array to reconstruct original + example: unique[inverse] == data + """ + hashes = self._hashable_rows(data, digits=digits) + garbage, unique, inverse = np.unique(hashes, + return_index=True, + return_inverse=True) + return unique, inverse + + def _hashable_rows(self, data, digits=None): + """ + We turn our array into integers based on the precision + given by digits and then put them in a hashable format. + Parameters + --------- + data: (n,m) input array + digits: how many digits to add to hash, if data is floating point + If none, TOL_MERGE will be turned into a digit count and used. + Returns + --------- + hashable: (n) length array of custom data which can be sorted + or used as hash keys + """ + # if there is no data return immediatly + if len(data) == 0: + return np.array([]) + + # get array as integer to precision we care about + as_int = self._float_to_int(data, digits=digits) + + # if it is flat integers already, return + if len(as_int.shape) == 1: + return as_int + + # if array is 2D and smallish, we can try bitbanging + # this is signifigantly faster than the custom dtype + if len(as_int.shape) == 2 and as_int.shape[1] <= 4: + # time for some righteous bitbanging + # can we pack the whole row into a single 64 bit integer + precision = int(np.floor(64 / as_int.shape[1])) + # if the max value is less than precision we can do this + if np.abs(as_int).max() < 2**(precision - 1): + # the resulting package + hashable = np.zeros(len(as_int), dtype=np.int64) + # loop through each column and bitwise xor to combine + # make sure as_int is int64 otherwise bit offset won't work + for offset, column in enumerate(as_int.astype(np.int64).T): + # will modify hashable in place + np.bitwise_xor(hashable, + column << (offset * precision), + out=hashable) + return hashable + + # reshape array into magical data type that is weird but hashable + dtype = np.dtype((np.void, as_int.dtype.itemsize * as_int.shape[1])) + # make sure result is contiguous and flat + hashable = np.ascontiguousarray(as_int).view(dtype).reshape(-1) + return hashable + + def _float_to_int(self, data, digits=None, dtype=np.int32): + """ + Given a numpy array of float/bool/int, return as integers. + Parameters + ------------- + data: (n, d) float, int, or bool data + digits: float/int precision for float conversion + dtype: numpy dtype for result + Returns + ------------- + as_int: data, as integers + """ + # convert to any numpy array + data = np.asanyarray(data) + + # if data is already an integer or boolean we're done + # if the data is empty we are also done + if data.dtype.kind in 'ib' or data.size == 0: + return data.astype(dtype) + + # populate digits from kwargs + if digits is None: + digits = self._decimal_to_digits(1e-8) + elif isinstance(digits, float) or isinstance(digits, np.float): + digits = self._decimal_to_digits(digits) + elif not (isinstance(digits, int) or isinstance(digits, np.integer)): + # log.warn('Digits were passed as %s!', digits.__class__.__name__) + raise ValueError('Digits must be None, int, or float!') + + # data is float so convert to large integers + data_max = np.abs(data).max() * 10**digits + # ignore passed dtype if we have something large + dtype = [np.int32, np.int64][int(data_max > 2**31)] + # multiply by requested power of ten + # then subtract small epsilon to avoid "go either way" rounding + # then do the rounding and convert to integer + as_int = np.round((data * 10 ** digits) - 1e-6).astype(dtype) + + return as_int + + + def _decimal_to_digits(self, decimal, min_digits=None): + """ + Return the number of digits to the first nonzero decimal. + Parameters + ----------- + decimal: float + min_digits: int, minumum number of digits to return + Returns + ----------- + digits: int, number of digits to the first nonzero decimal + """ + digits = abs(int(np.log10(decimal))) + if min_digits is not None: + digits = np.clip(digits, min_digits, 20) + return digits diff --git a/pygsp/graphs/sphereequiangular.py b/pygsp/graphs/sphereequiangular.py index 8c58b56f..04f6d914 100644 --- a/pygsp/graphs/sphereequiangular.py +++ b/pygsp/graphs/sphereequiangular.py @@ -67,15 +67,34 @@ class SphereEquiangular(Graph): Examples -------- >>> import matplotlib.pyplot as plt - >>> G = graphs.SphereEquiangular(bandwidth=8, sampling='SOFT') + >>> G1 = graphs.SphereEquiangular(bandwidth=6, sampling='Driscoll-Healy') + >>> G2 = graphs.SphereEquiangular(bandwidth=6, sampling='SOFT') + >>> G3 = graphs.SphereEquiangular(bandwidth=6, sampling='Clenshaw-Curtis') + >>> G4 = graphs.SphereEquiangular(bandwidth=6, sampling='Gauss-Legendre') >>> fig = plt.figure() - >>> ax1 = fig.add_subplot(121) - >>> ax2 = fig.add_subplot(122, projection='3d') - >>> _ = ax1.spy(G.W, markersize=1.5) - >>> _ = _ = G.plot(ax=ax2) + >>> plt.subplots_adjust(wspace=1.) + >>> ax1 = fig.add_subplot(221, projection='3d') + >>> ax2 = fig.add_subplot(222, projection='3d') + >>> ax3 = fig.add_subplot(223, projection='3d') + >>> ax4 = fig.add_subplot(224, projection='3d') + >>> _ = G1.plot(ax=ax1, title='Driscoll-Healy', vertex_size=10) + >>> _ = G2.plot(ax=ax2, title='SOFT', vertex_size=10) + >>> _ = G3.plot(ax=ax3, title='Clenshaw-Curtis', vertex_size=10) + >>> _ = G4.plot(ax=ax4, title='Gauss-Legendre', vertex_size=10) + >>> ax1.set_xlim([0, 1]) + >>> ax1.set_ylim([-1, 0.]) + >>> ax1.set_zlim([0.5, 1.]) + >>> ax2.set_xlim([0, 1]) + >>> ax2.set_ylim([-1, 0.]) + >>> ax2.set_zlim([0.5, 1.]) + >>> ax3.set_xlim([0, 1]) + >>> ax3.set_ylim([-1, 0.]) + >>> ax3.set_zlim([0.5, 1.]) + >>> ax4.set_xlim([0, 1]) + >>> ax4.set_ylim([-1, 0.]) + >>> ax4.set_zlim([0.5, 1.]) """ - # TODO add different plot to illustrate the different sampling schemes. Maybe zoom on some part of the sphere # TODO move OD in different file, as well as the cylinder def __init__(self, bandwidth=64, sampling='SOFT', distance_type='euclidean', **kwargs): if isinstance(bandwidth, int): @@ -209,10 +228,30 @@ def east(x): if __name__=='__main__': import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D - G = SphereEquiangular(bandwidth=(4, 8), sampling='Driscoll-Healy') # (384, 576) + G1 = SphereEquiangular(bandwidth=6, sampling='Driscoll-Healy') # (384, 576) + G2 = SphereEquiangular(bandwidth=6, sampling='SOFT') + G3 = SphereEquiangular(bandwidth=6, sampling='Clenshaw-Curtis') + G4 = SphereEquiangular(bandwidth=6, sampling='Gauss-Legendre') fig = plt.figure() - ax1 = fig.add_subplot(121) - ax2 = fig.add_subplot(122, projection='3d') - _ = ax1.spy(G.W, markersize=1.5) - _ = _ = G.plot(ax=ax2) + plt.subplots_adjust(wspace=1.) + ax1 = fig.add_subplot(221, projection='3d') + ax2 = fig.add_subplot(222, projection='3d') + ax3 = fig.add_subplot(223, projection='3d') + ax4 = fig.add_subplot(224, projection='3d') + _ = G1.plot(ax=ax1, title='Driscoll-Healy', vertex_size=10) + _ = G2.plot(ax=ax2, title='SOFT', vertex_size=10) + _ = G3.plot(ax=ax3, title='Clenshaw-Curtis', vertex_size=10) + _ = G4.plot(ax=ax4, title='Gauss-Legendre', vertex_size=10) + ax1.set_xlim([0, 1]) + ax1.set_ylim([-1, 0.]) + ax1.set_zlim([0.5, 1.]) + ax2.set_xlim([0, 1]) + ax2.set_ylim([-1, 0.]) + ax2.set_zlim([0.5, 1.]) + ax3.set_xlim([0, 1]) + ax3.set_ylim([-1, 0.]) + ax3.set_zlim([0.5, 1.]) + ax4.set_xlim([0, 1]) + ax4.set_ylim([-1, 0.]) + ax4.set_zlim([0.5, 1.]) plt.show() From 9d0db1f3c6941e4e5fb7dbe4b98c4f2688850b51 Mon Sep 17 00:00:00 2001 From: Droxef Date: Tue, 17 Sep 2019 18:54:56 +0200 Subject: [PATCH 363/365] modified spherehealpix with new kernel_widths --- pygsp/graphs/nngraphs/spherehealpix.py | 55 ++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index aed33d7c..15e8ef4a 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -51,28 +51,61 @@ class SphereHealpix(NNGraph): """ - def __init__(self, Nside=1024, nest=True, **kwargs): - # TODO: add part of sphere construction + def __init__(self, indexes=None, Nside=32, nest=True, kernel_width=None, n_neighbors=None, **kwargs): hp = _import_hp() self.Nside = Nside self.nest = nest npix = hp.nside2npix(Nside) - indexes = np.arange(npix) + if indexes is None: + indexes = np.arange(npix) x, y, z = hp.pix2vec(Nside, indexes, nest=nest) self.lat, self.lon = hp.pix2ang(Nside, indexes, nest=nest, lonlat=False) coords = np.vstack([x, y, z]).transpose() coords = np.asarray(coords, dtype=np.float32) ## TODO: n_neighbors in function of Nside - n_neighbors = 6 if Nside==1 else 8 - ## TODO: find optimal sigmas - opt_std = {1: 0.5, 2: 0.15, 4: 0.05, 8: 0.0125, 16: 0.005, 32: 0.001} - try: - sigma = opt_std[Nside] - except: - raise ValueError('Unknown sigma for nside>32') + if n_neighbors is None: + n_neighbors = 6 if Nside==1 else 8 + if Nside>=4: + n_neighbors = 50 + elif Nside == 2: + n_neighbors = 47 + else: + n_neighbors = 11 + if len(indexes)<50: + n_neighbors = len(indexes)-1 + ## TODO: find optimal sigmas (for n_neighbors = 50) + """opt_std = {1:1.097324009878543, + 2:1.097324042581347, + 4: 0.5710655156439823, + 8: 0.28754191240507265, + 16: 0.14552024595543614, + 32: 0.07439700765663292, + 64: 0.03654101726025044, + 128: 0.018262391329213392, + 256: 0.009136370875837834, + 512: 0.004570016186845779, + 1024: 0.0022857004460788742,} + """ + opt_std = {1:1.097324009878543, + 2:1.097324042581347, + 4: 0.5710655156439823, + 8: 0.28754191240507265, + 16: 0.14552024595543614, + 32: 0.05172026, ### from nside=32 on it was obtained by equivariance error minimization + 64: 0.0254030519, + 128: 0.01269588289, + 256: 0.00635153921, + 512: 0.002493215645,} + try: + kernel_width = opt_std[Nside] + except: + raise ValueError('Unknown sigma for nside>32') + ## TODO: check std plotting = { 'vertex_size': 80, "limits": np.array([-1, 1, -1, 1, -1, 1]) } - super(SphereHealpix, self).__init__(coords, k=n_neighbors, kernel_width=np.sqrt(2*sigma), plotting=plotting, **kwargs) + super(SphereHealpix, self).__init__(features=coords, k=n_neighbors, + kernel_width=kernel_width, plotting=plotting, **kwargs) + From 6b216395beae25bf062d13fbf9abc251eeb5bbff Mon Sep 17 00:00:00 2001 From: Droxef Date: Tue, 24 Sep 2019 15:56:47 +0200 Subject: [PATCH 364/365] healpix graph with optimal kernel widths --- pygsp/graphs/nngraphs/nngraph.py | 7 +- pygsp/graphs/nngraphs/spherehealpix.py | 98 +++++++++++++++----------- 2 files changed, 61 insertions(+), 44 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 786dad73..22246bfa 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -414,9 +414,14 @@ def __init__(self, features, standardize=False, function = globals()['_' + backend.replace('-', '_')] except KeyError: raise ValueError('Invalid backend "{}".'.format(backend)) + + neighbors, distances = function(features, metric, order, kind, k, radius, kwargs) - + # ------ MARTINO's MODIFICATION ------ + self.distances = distances + # ------------------------------------ + n_edges = [len(n) - 1 for n in neighbors] # remove distance to self if kind == 'radius': diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index 15e8ef4a..b5f227c4 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -42,7 +42,7 @@ class SphereHealpix(NNGraph): Examples -------- >>> import matplotlib.pyplot as plt - >>> G = graphs.SphereHealpix(Nside=4) + >>> G = graphs.SphereHealpix(nside=4) >>> fig = plt.figure() >>> ax1 = fig.add_subplot(121) >>> ax2 = fig.add_subplot(122, projection='3d') @@ -51,57 +51,69 @@ class SphereHealpix(NNGraph): """ - def __init__(self, indexes=None, Nside=32, nest=True, kernel_width=None, n_neighbors=None, **kwargs): + def __init__(self, indexes=None, nside=32, nest=True, kernel_width=None, n_neighbors=50, **kwargs): hp = _import_hp() - self.Nside = Nside + self.nside = nside self.nest = nest - npix = hp.nside2npix(Nside) + npix = hp.nside2npix(nside) if indexes is None: indexes = np.arange(npix) - x, y, z = hp.pix2vec(Nside, indexes, nest=nest) - self.lat, self.lon = hp.pix2ang(Nside, indexes, nest=nest, lonlat=False) + x, y, z = hp.pix2vec(nside, indexes, nest=nest) + self.lat, self.lon = hp.pix2ang(nside, indexes, nest=nest, lonlat=False) coords = np.vstack([x, y, z]).transpose() coords = np.asarray(coords, dtype=np.float32) - ## TODO: n_neighbors in function of Nside if n_neighbors is None: n_neighbors = 6 if Nside==1 else 8 - if Nside>=4: - n_neighbors = 50 - elif Nside == 2: - n_neighbors = 47 - else: - n_neighbors = 11 - if len(indexes)<50: - n_neighbors = len(indexes)-1 - ## TODO: find optimal sigmas (for n_neighbors = 50) - """opt_std = {1:1.097324009878543, - 2:1.097324042581347, - 4: 0.5710655156439823, - 8: 0.28754191240507265, - 16: 0.14552024595543614, - 32: 0.07439700765663292, - 64: 0.03654101726025044, - 128: 0.018262391329213392, - 256: 0.009136370875837834, - 512: 0.004570016186845779, - 1024: 0.0022857004460788742,} - """ - opt_std = {1:1.097324009878543, - 2:1.097324042581347, - 4: 0.5710655156439823, - 8: 0.28754191240507265, - 16: 0.14552024595543614, - 32: 0.05172026, ### from nside=32 on it was obtained by equivariance error minimization - 64: 0.0254030519, - 128: 0.01269588289, - 256: 0.00635153921, - 512: 0.002493215645,} - - try: - kernel_width = opt_std[Nside] - except: - raise ValueError('Unknown sigma for nside>32') + + self.opt_std = dict() + self.opt_std[20] = { + 32: 0.03185, + 64: 0.01564, + 128: 0.00782, + 256: 0.00391, + 512: 0.00196, + 1024: 0.00098, + } + self.opt_std[40] = { + 32: 0.042432, + 64: 0.021354, + 128: 0.010595, + 256: 0.005551, # seems a bit off + #512: 0.003028, # seems buggy + 512: 0.005551 / 2, # extrapolated + 1024: 0.005551 / 4, # extrapolated + } + self.opt_std[60] = { + 32: 0.051720, + 64: 0.025403, + 128: 0.012695, + 256: 0.006351, + #512: 0.002493, # seems buggy + 512: 0.006351 / 2, # extrapolated + 1024: 0.006351 / 4, # extrapolated + } + self.opt_std[8] = { + 32: 0.02500, + 64: 0.01228, + 128: 0.00614, + 256: 0.00307, + 512: 0.00154, + 1024: 0.00077, + } + try: + kernel_dict = self.opt_std[n_neighbors] + except: + raise ValueError('No sigma for number of neighbors {}'.format(n_neighbors)) + try: + kernel_width = kernel_dict[nside] + except: + raise ValueError('Unknown sigma for nside {}'.format(nside)) ## TODO: check std + + ## TODO: n_neighbors in function of Nside + if len(indexes) <= n_neighbors: + n_neighbors = len(indexes)-1 + plotting = { 'vertex_size': 80, "limits": np.array([-1, 1, -1, 1, -1, 1]) From c69398b10fee3b5d834c488052330b48ecede2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 21 Jan 2020 14:01:57 +0100 Subject: [PATCH 365/365] healpix: extrapolate kernel width from nside = 1 to 2048 --- pygsp/graphs/nngraphs/spherehealpix.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/pygsp/graphs/nngraphs/spherehealpix.py b/pygsp/graphs/nngraphs/spherehealpix.py index b5f227c4..67ecb542 100644 --- a/pygsp/graphs/nngraphs/spherehealpix.py +++ b/pygsp/graphs/nngraphs/spherehealpix.py @@ -65,16 +65,29 @@ def __init__(self, indexes=None, nside=32, nest=True, kernel_width=None, n_neigh if n_neighbors is None: n_neighbors = 6 if Nside==1 else 8 + self.opt_std = dict() + # TODO: find best interpolator between n_side and n_neighbors. self.opt_std = dict() self.opt_std[20] = { + 1: 0.03185 * 32, # extrapolated + 2: 0.03185 * 16, # extrapolated + 4: 0.03185 * 8, # extrapolated + 8: 0.03185 * 4, # extrapolated + 16: 0.03185 * 2, # extrapolated 32: 0.03185, 64: 0.01564, 128: 0.00782, 256: 0.00391, 512: 0.00196, 1024: 0.00098, + 2048: 0.00098 / 2, # extrapolated } self.opt_std[40] = { + 1: 0.042432 * 32, # extrapolated + 2: 0.042432 * 16, # extrapolated + 4: 0.042432 * 8, # extrapolated + 8: 0.042432 * 4, # extrapolated + 16: 0.042432 * 2, # extrapolated 32: 0.042432, 64: 0.021354, 128: 0.010595, @@ -82,8 +95,14 @@ def __init__(self, indexes=None, nside=32, nest=True, kernel_width=None, n_neigh #512: 0.003028, # seems buggy 512: 0.005551 / 2, # extrapolated 1024: 0.005551 / 4, # extrapolated + 2048: 0.005551 / 8, # extrapolated } self.opt_std[60] = { + 1: 0.051720 * 32, # extrapolated + 2: 0.051720 * 16, # extrapolated + 4: 0.051720 * 8, # extrapolated + 8: 0.051720 * 4, # extrapolated + 16: 0.051720 * 2, # extrapolated 32: 0.051720, 64: 0.025403, 128: 0.012695, @@ -91,14 +110,21 @@ def __init__(self, indexes=None, nside=32, nest=True, kernel_width=None, n_neigh #512: 0.002493, # seems buggy 512: 0.006351 / 2, # extrapolated 1024: 0.006351 / 4, # extrapolated + 2048: 0.006351 / 8, # extrapolated } self.opt_std[8] = { + 1: 0.02500 * 32, # extrapolated + 2: 0.02500 * 16, # extrapolated + 4: 0.02500 * 8, # extrapolated + 8: 0.02500 * 4, # extrapolated + 16: 0.02500 * 2, # extrapolated 32: 0.02500, 64: 0.01228, 128: 0.00614, 256: 0.00307, 512: 0.00154, 1024: 0.00077, + 2048: 0.00077 / 2, # extrapolated } try: kernel_dict = self.opt_std[n_neighbors]