From 0d28fef251330ca1399ddc7c759d56768cf2451a Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Thu, 18 May 2017 15:07:57 +0200 Subject: [PATCH 001/392] fix bug in swissroll --- .gitignore | 3 +++ pygsp/graphs/swissroll.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ea4fa61c..2a7b00c7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ docs/_build .ropeproject .eggs/* + +# Mac OS garbage +.DS_Store diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 52b12f45..201a10c8 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -42,7 +42,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, noise=False, srtype='uniform'): if s is None: - s = sqrt(2./N) + s = sqrt(2. / N) y1 = np.random.rand(N) y2 = np.random.rand(N) @@ -54,9 +54,9 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, tt *= pi if dim == 2: - x = np.array((tt*np.cos(tt), tt * np.sin(tt))) + x = np.array((tt * np.cos(tt), tt * np.sin(tt))) elif dim == 3: - x = np.array((tt*np.cos(tt), 21 * y2, tt * np.sin(tt))) + x = np.array((tt * np.cos(tt), 21 * y2, tt * np.sin(tt))) if noise: x += np.random.randn(*x.shape) @@ -64,12 +64,12 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, self.x = x self.dim = dim + coords = self.rescale_center(x) dist = distanz(coords) W = np.exp(-np.power(dist, 2) / (2. * s**2)) W -= np.diag(np.diag(W)) W[W < thresh] = 0 - coords = self.rescale_center(x) plotting = {'limits': np.array([-1, 1, -1, 1, -1, 1])} gtype = 'swiss roll {}'.format(srtype) From 312bd4d73a175079af9bde2cf46a4c187232d14e Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Thu, 18 May 2017 17:38:30 +0200 Subject: [PATCH 002/392] fix grad, div; enable combinatorial laplacian option --- pygsp/filters/filter.py | 32 ++++++++++++++----------- pygsp/operators/operator.py | 47 +++++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 53490aff..74335457 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -66,7 +66,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): """ if not method: method = 'exact' if hasattr(self.G, 'U') else 'cheby' - self.logger.info('The analysis method is {}'.format(method)) + self.logger.info('The analysis method is {}'.format(method)) if method == 'cheby': # Chebyshev approx if not hasattr(self.G, 'lmax'): @@ -108,12 +108,12 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): tmpN = np.arange(N, dtype=int) for i in range(Nf): if is2d: - c[tmpN + N*i] =\ + c[tmpN + N * i] =\ operator.igft(self.G, np.tile(fie[i], (Ns, 1)).T * operator.gft(self.G, s)) else: - c[tmpN + N*i] = operator.igft(self.G, fie[i] * - operator.gft(self.G, s)) + c[tmpN + N * i] = operator.igft(self.G, fie[i] * + operator.gft(self.G, s)) else: raise ValueError('Unknown method: please select exact, ' @@ -215,7 +215,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): for i in range(Nf): s += operator.igft(np.conjugate(self.G.U), np.tile(fie[:][i], (Nv, 1)).T * - operator.gft(self.G, c[N*i + tmpN])) + operator.gft(self.G, c[N * i + tmpN])) elif method == 'cheby': if hasattr(self.G, 'lmax'): @@ -223,19 +223,21 @@ def synthesis(self, c, order=30, method=None, **kwargs): 'The function will compute it for you.') self.G.estimate_lmax() - cheb_coeffs = fast_filtering.compute_cheby_coeff(self, m=order, N=order + 1) + cheb_coeffs = fast_filtering.compute_cheby_coeff( + self, m=order, N=order + 1) s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) for i in range(Nf): - s = s + operator.cheby_op(self.G, cheb_coeffs[i], c[i*N + tmpN]) + s = s + operator.cheby_op(self.G, + cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) for i in range(Nf): - s += fast_filtering.lanczos_op(self.G, self.g[i], c[i*N + tmpN], + s += fast_filtering.lanczos_op(self.G, self.g[i], c[i * N + tmpN], order=order) else: @@ -285,7 +287,8 @@ def filterbank_bounds(self, N=999, bounds=None): self.G.estimate_lmax() if not hasattr(self.G, 'e'): - self.logger.info('FILTERBANK_BOUNDS: Has to compute Fourier basis.') + self.logger.info( + 'FILTERBANK_BOUNDS: Has to compute Fourier basis.') self.G.compute_fourier_basis() rng = self.G.e @@ -309,7 +312,8 @@ def filterbank_matrix(self): N = self.G.N if N > 2000: - self.logger.warning('Creating a big matrix, you can use other methods.') + self.logger.warning( + 'Creating a big matrix, you can use other methods.') Nf = len(self.g) Ft = self.analysis(np.identity(N)) @@ -317,7 +321,7 @@ def filterbank_matrix(self): tmpN = np.arange(N, dtype=int) for i in range(Nf): - F[:, N*i + tmpN] = Ft[N*i + tmpN] + F[:, N * i + tmpN] = Ft[N * i + tmpN] return F @@ -340,8 +344,8 @@ def wlog_scales(self, lmin, lmax, Nscales, t1=1, t2=2): Scale """ - smin = t1/lmax - smax = t2/lmin + smin = t1 / lmax + smax = t2 / lmin s = np.exp(np.linspace(log(smax), log(smin), Nscales)) @@ -360,7 +364,7 @@ def can_dual_func(g, n, x): s = np.zeros((N, M)) for i in range(N): - s[i] = np.linalg.pinv(np.expand_dims(gcoeff[i], axis=1)) + s[i] = np.linalg.pinv(np.expand_dims(gcoeff[i], axis=1)) ret = s[:, n] return ret diff --git a/pygsp/operators/operator.py b/pygsp/operators/operator.py index c2c4a7d2..d8bf6182 100644 --- a/pygsp/operators/operator.py +++ b/pygsp/operators/operator.py @@ -26,12 +26,8 @@ def div(G, s): The graph divergence """ - if hasattr(G, 'lap_type'): - if G.lap_type == 'combinatorial': - raise NotImplementedError('Not implemented yet. However ask Nathanael it is very easy.') - if G.Ne != np.shape(s)[0]: - raise ValueError('Signal size not equal to number of edges.') + raise ValueError('Signal size is different from the number of edges.') D = grad_mat(G) di = D.T * s @@ -66,9 +62,8 @@ def grad(G, s): Gradient living on the edges """ - if hasattr(G, 'lap_type'): - if G.lap_type == 'combinatorial': - raise NotImplementedError('Not implemented yet. However ask Nathanael it is very easy.') + if G.N != np.shape(s)[0]: + raise ValueError('Signal size is different from the number of nodes.') D = grad_mat(G) gr = D * s @@ -103,18 +98,33 @@ def grad_mat(G): # 1 call (above) adj2vec(G) if hasattr(G, 'Diff'): + if not sparse.issparse(G.Diff): + G.Diff = sparse.csc_matrix(G.Diff) D = G.Diff else: n = G.Ne - Dc = np.ones((2 * n)) - Dv = np.ones((2 * n)) - Dr = np.concatenate((np.arange(n), np.arange(n))) + Dc = np.ones((2 * n)) Dc[:n] = G.v_in Dc[n:] = G.v_out - Dv[:n] = np.sqrt(G.weights.toarray()) - Dv[n:] = -Dv[:n] + Dv = np.ones((2 * n)) + + try: + if G.lap_type == 'combinatorial': + Dv[:n] = np.sqrt(G.weights.toarray()) + Dv[n:] = -Dv[:n] + + elif G.lap_type == 'normalized': + Dv[:n] = np.sqrt(G.weights.toarray() / G.d[G.v_in]) + Dv[n:] = -np.sqrt(G.weights.toarray() / G.d[G.v_out]) + + else: + raise NotImplementedError('grad not implemented yet for ' + + 'this type of graph Laplacian.') + except AttributeError as err: + print('Graph does not have lap_type attribute: ' + str(err)) + D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, G.N)) G.Diff = D @@ -141,7 +151,8 @@ def gft(G, f): if isinstance(G, Graph): if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues and the eigenvectors.') + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') G.compute_fourier_basis() U = G.U @@ -172,7 +183,8 @@ def igft(G, f_hat): if isinstance(G, Graph): if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues and the eigenvectors.') + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') G.compute_fourier_basis() U = G.U @@ -227,7 +239,8 @@ def modulate(G, f, k): """ nt = np.shape(f)[1] - fm = np.sqrt(G.N)*np.kron(np.ones((nt, 1)), f)*np.kron(np.ones((1, nt)), G.U[:, k]) + fm = np.sqrt(G.N) * np.kron(np.ones((nt, 1)), f) * \ + np.kron(np.ones((1, nt)), G.U[:, k]) return fm @@ -253,6 +266,6 @@ def translate(G, f, i): fhat = gft(G, f) nt = np.shape(f)[1] - ft = np.sqrt(G.N)*igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) + ft = np.sqrt(G.N) * igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) return ft From 296bc9e64a825422e2afca66c05ac7dc3708d5e1 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Thu, 1 Jun 2017 18:59:41 +0100 Subject: [PATCH 003/392] first commit --- pygsp/graphs/__init__.py | 28 ++++- pygsp/graphs/graph.py | 140 ++++++++++++++-------- pygsp/graphs/grid2d.py | 40 +++---- pygsp/graphs/nngraphs/__init__.py | 15 ++- pygsp/graphs/nngraphs/imgpatches.py | 73 +++++++++++ pygsp/graphs/nngraphs/imgpatchesgrid2d.py | 57 +++++++++ pygsp/utils.py | 41 ++++++- setup.py | 16 ++- 8 files changed, 325 insertions(+), 85 deletions(-) create mode 100644 pygsp/graphs/nngraphs/imgpatches.py create mode 100644 pygsp/graphs/nngraphs/imgpatchesgrid2d.py diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 25c065fb..c5537163 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -10,14 +10,34 @@ import importlib import sys -__all__ = ['Graph', 'Airfoil', 'BarabasiAlbert', 'Comet', 'Community', 'DavidSensorNet', 'ErdosRenyi', 'FullConnected', 'Grid2d', 'Logo', - 'LowStretchTree', 'Minnesota', 'Path', 'RandomRing', 'RandomRegular', 'Ring', 'Sensor', 'StochasticBlockModel', 'SwissRoll', 'Torus'] - +__all__ = ['Graph', + 'Airfoil', + 'BarabasiAlbert', + 'Comet', + 'Community', + 'DavidSensorNet', + 'ErdosRenyi', + 'FullConnected', + 'Grid2d', + 'Logo', + 'LowStretchTree', + 'Minnesota', + 'Path', + 'RandomRing', + 'RandomRegular', + 'Ring', + 'Sensor', + 'StochasticBlockModel', + 'SwissRoll', + 'Torus'] # Automaticaly import all classes from subfiles defined in __all__ for class_to_import in __all__: - setattr(sys.modules[__name__], class_to_import, getattr(importlib.import_module('.' + class_to_import.lower(), 'pygsp.graphs'), class_to_import)) + setattr(sys.modules[__name__], class_to_import, + getattr(importlib.import_module('.' + class_to_import.lower(), + 'pygsp.graphs'), + class_to_import)) from .nngraphs import * from .gutils import * diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 2c9446ef..8156f7ab 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -68,8 +68,9 @@ class Graph(object): >>> G = graphs.Graph(W) """ + def __init__(self, W, gtype='unknown', lap_type='combinatorial', - coords=None, plotting={}, **kwargs): + coords=None, plotting={}, perform_all_checks=True, **kwargs): self.logger = build_logger(__name__, **kwargs) @@ -87,9 +88,19 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.gtype = gtype self.lap_type = lap_type - self.is_connected() - if not self.connected: - self.logger.warning('Graph is not connected!') + # (Rodrigo): This check was inside self.is_connected(), but they should + # be independent of each other. + if not hasattr(self, 'directed'): + self.is_directed() + + # (Rodrigo): I don't think this should be called by default when we + # create the graph. It is very expensive for big graphs. For now I kept + # the default behavior as is, but added a flag that allows the user to + # turn this off. + if perform_all_checks: + self.is_connected() + if not self.connected: + self.logger.warning('Graph is not connected!') self.create_laplacian(lap_type) @@ -146,16 +157,19 @@ def update_graph_attr(self, *args, **kwargs): if i in valid_attributes: graph_attr[i] = getattr(self, i) else: - self.logger.warning('Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) + self.logger.warning( + 'Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) for i in kwargs: if i in valid_attributes: if i in graph_attr: - self.logger.info('You already give this attribute in the args. Therefore, it will not be recaculate.') + self.logger.info( + 'You already give this attribute in the args. Therefore, it will not be recaculate.') else: graph_attr[i] = kwargs[i] else: - self.logger.warning('Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) + self.logger.warning( + 'Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) from nngraphs import NNGraph if isinstance(self, NNGraph): @@ -234,12 +248,13 @@ def set_coords(self, kind='spring', **kwargs): coords.shape[0] == self.N and 2 <= coords.shape[1] <= 3: self.coords = coords else: - raise ValueError('Expecting coords to be a list or ndarray of size Nx2 or Nx3.') + raise ValueError( + 'Expecting coords to be a list or ndarray of size Nx2 or Nx3.') elif kind == 'ring2D': tmp = np.arange(self.N).reshape(self.N, 1) - self.coords = np.concatenate((np.cos(tmp*2*np.pi/self.N), - np.sin(tmp*2*np.pi/self.N)), + self.coords = np.concatenate((np.cos(tmp * 2 * np.pi / self.N), + np.sin(tmp * 2 * np.pi / self.N)), axis=1) elif kind == 'random2D': @@ -253,14 +268,16 @@ def set_coords(self, kind='spring', **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.') + 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())]) + self.info['comm_sizes'] = np.array( + [cnt[1] for cnt in sorted(counts.items())]) Nc = self.info['comm_sizes'].shape[0] @@ -268,15 +285,18 @@ def set_coords(self, kind='spring', **kwargs): np.cos(2 * np.pi * np.arange(1, Nc + 1) / Nc), np.sin(2 * np.pi * np.arange(1, Nc + 1) / Nc)))) - coords = np.random.rand(self.N, 2) # nodes' coordinates inside the community + # nodes' coordinates inside the community + 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]) + 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 + # 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] + self.coords[i] = self.info['com_coords'][ + comm_idx] + comm_rad * self.coords[i] def subgraph(self, ind): r""" @@ -315,10 +335,12 @@ def is_connected(self, force_recompute=False): Check the strong connectivity of the input graph. 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. + 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. Parameters ---------- @@ -341,16 +363,16 @@ def is_connected(self, force_recompute=False): """ if hasattr(self, 'force_recompute'): if force_recompute: - self.logger.warning("Connectivity for this graph is already known. Recomputing.") + self.logger.warning( + "Connectivity for this graph is already known. Recomputing.") else: - self.logger.error("Connectivity for this graph is already known. Stopping.") + self.logger.error( + "Connectivity for this graph is already known. Stopping.") return self.connected - if not hasattr(self, 'directed'): - self.is_directed() - if self.A.shape[0] != self.A.shape[1]: - self.logger.error('Inconsistant shape to test connectedness. Set to False.') + self.logger.error( + 'Inconsistant shape to test connectedness. Set to False.') self.connected = False return False @@ -363,8 +385,10 @@ def is_connected(self, force_recompute=False): if not visited[v]: visited[v] = True - # 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]])) + # 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 not visited.all(): self.connected = False @@ -405,7 +429,8 @@ def is_directed(self, force_recompute=False): return self.directed if np.diff(np.shape(self.W))[0]: - raise ValueError("Matrix dimensions mismatch, expecting square matrix.") + raise ValueError( + "Matrix dimensions mismatch, expecting square matrix.") is_dir = np.abs(self.W - self.W.T).sum() != 0 @@ -440,7 +465,8 @@ def extract_components(self): self.is_directed() if self.A.shape[0] != self.A.shape[1]: - self.logger.error('Inconsistant shape to extract components. Square matrix required.') + self.logger.error( + 'Inconsistant shape to extract components. Square matrix required.') return None if self.directed: @@ -461,18 +487,22 @@ def extract_components(self): comp.append(v) visited[v] = True - # Add indices of nodes not visited yet and accessible from v - stack.update(set([idx for idx in self.A[v, :].nonzero()[1] if not visited[idx]])) + # Add indices of nodes not visited yet and accessible from + # v + stack.update( + set([idx for idx in self.A[v, :].nonzero()[1] if not visited[idx]])) comp = sorted(comp) - self.logger.info('Constructing subgraph for component of size {}.'.format(len(comp))) + self.logger.info( + 'Constructing subgraph for component of size {}.'.format(len(comp))) G = self.subgraph(comp) G.info = {'orig_idx': comp} graphs.append(G) return graphs - def compute_fourier_basis(self, smallest_first=True, force_recompute=False, **kwargs): + def compute_fourier_basis(self, smallest_first=True, force_recompute=False, + **kwargs): r""" Compute the fourier basis of the graph. @@ -515,14 +545,17 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, **kw """ if hasattr(self, 'e') or hasattr(self, 'U'): if force_recompute: - self.logger.warning("This graph already has a Fourier basis. Recomputing.") + self.logger.warning( + "This graph already has a Fourier basis. Recomputing.") else: - self.logger.error("This graph already has a Fourier basis. Stopping.") + self.logger.error( + "This graph already has a Fourier basis. Stopping.") return if self.N > 3000: - self.logger.warning("Performing full eigendecomposition of a large " - "matrix may take some time.") + self.logger.warning( + "Performing full eigendecomposition of a large " + "matrix may take some time.") if not hasattr(self, 'L'): raise AttributeError("Graph Laplacian is missing") @@ -545,7 +578,9 @@ def create_laplacian(self, lap_type='combinatorial'): Parameters ---------- lap_type : string - The laplacian type to use. Default is "combinatorial". Other possible value is 'none', 'normalized' is still not yet implemented for directed graphs. + The laplacian type to use. Default is "combinatorial". Other + possible values are 'none' and 'normalized', which are not yet + implemented for directed graphs. """ if np.shape(self.W) == (1, 1): @@ -559,7 +594,8 @@ def create_laplacian(self, lap_type='combinatorial'): if self.directed: if lap_type == 'combinatorial': - L = 0.5*(sparse.diags(np.ravel(self.W.sum(0)), 0) + sparse.diags(np.ravel(self.W.sum(1)), 0) - self.W - self.W.T).tocsc() + L = 0.5 * (sparse.diags(np.ravel(self.W.sum(0)), 0) + + sparse.diags(np.ravel(self.W.sum(1)), 0) - self.W - self.W.T).tocsc() elif lap_type == 'normalized': raise NotImplementedError('Yet. Ask Nathanael.') elif lap_type == 'none': @@ -569,7 +605,8 @@ def create_laplacian(self, lap_type='combinatorial'): if lap_type == 'combinatorial': L = (sparse.diags(np.ravel(self.W.sum(1)), 0) - self.W).tocsc() elif lap_type == 'normalized': - D = sparse.diags(np.ravel(np.power(self.W.sum(1), -0.5)), 0).tocsc() + D = sparse.diags( + np.ravel(np.power(self.W.sum(1), -0.5)), 0).tocsc() L = sparse.identity(self.N) - D * self.W * D elif lap_type == 'none': L = sparse.lil_matrix(0) @@ -605,10 +642,12 @@ def estimate_lmax(self, force_recompute=False): try: # On robustness purposes, increasing the error by 1 percent - lmax = 1.01 * sparse.linalg.eigs(self.L, k=1, tol=5e-3, ncv=10)[0][0] + lmax = 1.01 * \ + sparse.linalg.eigs(self.L, k=1, tol=5e-3, ncv=10)[0][0] except sparse.linalg.ArpackNoConvergence: - self.logger.warning('GSP_ESTIMATE_LMAX: Cannot use default method.') + self.logger.warning( + 'GSP_ESTIMATE_LMAX: Cannot use default method.') lmax = 2. * np.max(self.d) lmax = np.real(lmax) @@ -667,7 +706,8 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], if k is None and fixed is not None: # 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) + pos = _sparse_fruchterman_reingold( + self.A, dim, k, pos_arr, fixed, iterations) if fixed is None: pos = _rescale_layout(pos, scale=scale) + center @@ -695,12 +735,12 @@ def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, # optimal distance between nodes if k is None: - k = np.sqrt(1.0/nnodes) + 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) + dt = t / float(iterations + 1) displacement = np.zeros((dim, nnodes)) for iteration in range(iterations): @@ -710,7 +750,7 @@ def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, if i in fixed: continue # difference between this row's node position and all others - delta = (pos[i]-pos).T + delta = (pos[i] - pos).T # distance between points distance = np.sqrt((delta**2).sum(axis=0)) # enforce minimum distance of 0.01 @@ -719,11 +759,11 @@ def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, Ai = np.asarray(A[i, :].toarray()) # displacement "force" displacement[:, i] += \ - (delta*(k*k/distance**2-Ai*distance/k)).sum(axis=1) + (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 + pos += (displacement * t / length).T # cool temperature t -= dt @@ -740,5 +780,5 @@ def _rescale_layout(pos, scale=1): 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 + pos[:, i] *= scale / lim return pos diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 4a2167dc..86388ded 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -7,7 +7,7 @@ class Grid2d(Graph): r""" - Create a 2 dimensional grid graph. + Create a 2-dimensional grid graph. Parameters ---------- @@ -28,36 +28,36 @@ def __init__(self, Nv=16, Mv=None, **kwargs): Mv = Nv # Create weighted adjacency matrix - K = 2*(Nv - 1) - J = 2*(Mv - 1) + K = 2 * (Nv - 1) + J = 2 * (Mv - 1) - i_inds = np.zeros((K*Mv + J*Nv), dtype=float) - j_inds = np.zeros((K*Mv + J*Nv), dtype=float) + i_inds = np.zeros((K * Mv + J * Nv), dtype=float) + j_inds = np.zeros((K * Mv + J * Nv), dtype=float) tmpK = np.arange(K, dtype=int) tmpNv1 = np.arange(Nv - 1) for i in range(Mv): - i_inds[i*K + tmpK] = i*Nv + \ + i_inds[i * K + tmpK] = i * Nv + \ np.concatenate((tmpNv1, tmpNv1 + 1)) - j_inds[i*K + tmpK] = i*Nv + \ + j_inds[i * K + tmpK] = i * Nv + \ np.concatenate((tmpNv1 + 1, tmpNv1)) - tmp2Nv = np.arange(2*Nv, dtype=int) + tmp2Nv = np.arange(2 * Nv, dtype=int) tmpNv = np.arange(Nv) - for i in range(Mv-1): - i_inds[(K*Mv) + i*2*Nv + tmp2Nv] = \ - np.concatenate((i*Nv + tmpNv, (i + 1)*Nv + tmpNv)) + for i in range(Mv - 1): + i_inds[(K * Mv) + i * 2 * Nv + tmp2Nv] = \ + np.concatenate((i * Nv + tmpNv, (i + 1) * Nv + tmpNv)) - j_inds[(K*Mv) + i*2*Nv + tmp2Nv] = \ - np.concatenate(((i + 1)*Nv + tmpNv, i*Nv + tmpNv)) + j_inds[(K * Mv) + i * 2 * Nv + tmp2Nv] = \ + np.concatenate(((i + 1) * Nv + tmpNv, i * Nv + tmpNv)) - W = sparse.csc_matrix((np.ones((K*Mv + J*Nv)), (i_inds, j_inds)), - shape=(Mv*Nv, Mv*Nv)) + W = sparse.csc_matrix((np.ones((K * Mv + J * Nv)), (i_inds, j_inds)), + shape=(Mv * Nv, Mv * Nv)) - xtmp = np.kron(np.ones((Mv, 1)), (np.arange(Nv)/float(Nv)).reshape(Nv, - 1)) + xtmp = np.kron(np.ones((Mv, 1)), (np.arange(Nv) / float(Nv)).reshape(Nv, + 1)) ytmp = np.sort(np.kron(np.ones((Nv, 1)), - np.arange(Mv)/float(Mv)).reshape(Mv*Nv, 1), + np.arange(Mv) / float(Mv)).reshape(Mv * Nv, 1), axis=0) coords = np.concatenate((xtmp, ytmp), axis=1) @@ -65,8 +65,8 @@ def __init__(self, Nv=16, Mv=None, **kwargs): self.Nv = Nv self.Mv = Mv plotting = {"vertex_size": 30, - "limits": np.array([-1./self.Nv, 1 + 1./self.Nv, - 1./self.Mv, 1 + 1./self.Mv])} + "limits": np.array([-1. / self.Nv, 1 + 1. / self.Nv, + 1. / self.Mv, 1 + 1. / self.Mv])} super(Grid2d, self).__init__(W=W, gtype='2d-grid', coords=coords, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/__init__.py b/pygsp/graphs/nngraphs/__init__.py index e942c531..454a79c6 100644 --- a/pygsp/graphs/nngraphs/__init__.py +++ b/pygsp/graphs/nngraphs/__init__.py @@ -4,6 +4,17 @@ import sys -__all__ = ['NNGraph', 'Bunny', 'Cube', 'Sphere', 'TwoMoons'] +__all__ = ['NNGraph', + 'Bunny', + 'Cube', + 'Sphere', + 'TwoMoons', + 'ImgPatches', + 'ImgPatchesGrid2d'] + for class_to_import in __all__: - setattr(sys.modules[__name__], class_to_import, getattr(importlib.import_module('.' + class_to_import.lower(), 'pygsp.graphs.nngraphs'), class_to_import)) + setattr(sys.modules[__name__], + class_to_import, + getattr(importlib.import_module('.' + class_to_import.lower(), + 'pygsp.graphs.nngraphs'), + class_to_import)) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py new file mode 100644 index 00000000..d404b649 --- /dev/null +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +import numpy as np +from scipy import sparse +from skimage.util import view_as_windows, pad +from pyflann import * +from .. import Graph +from ... import utils + + +class ImgPatches(Graph): + r""" + Create a nearest neighbors graph from patches of an image. + + Parameters + ---------- + img : array + Input image. + patch_shape : tuple, optional + Dimensions of the patch window. Syntax : (height, width). + n_nbrs : int + Number of neighbors to consider + dist_type : string + Type of distance between patches to compute. See + :func:`pyflann.index.set_distance_type` for possible options. + + Examples + -------- + >>> from pygsp.graphs import nngraphs + >>> from skimage import data, img_as_float + >>> img = img_as_float(data.camera()[::2, ::2]) + >>> G = nngraphs.ImgPatches(img) + + """ + + def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, + dist_type='euclidean', **kwargs): + try: + h, w, _ = img.shape + except ValueError: + try: + h, w = img.shape + except ValueError: + print("Image should be a 2-d array.") + + pad_width = (int((patch_shape[0] - 1) / 2), + int((patch_shape[1] - 1) / 2)) + img_pad = pad(img, pad_width=pad_width, mode='edge') + patches = view_as_windows(img_pad, window_shape=patch_shape) + X = patches.reshape((h * w, patch_shape[0] * patch_shape[1])) + + set_distance_type(dist_type) + flann = FLANN() + nbrs, dists = flann.nn(X, X, num_neighbors=(n_nbrs + 1), + algorithm="kmeans", branching=32, iterations=7, + checks=16) + + node_list = [[i] * n_nbrs for i in range(h * w)] + node_list = [item for sublist in node_list for item in sublist] + nbrs = nbrs[:, 1:].reshape((len(node_list),)) + dists = dists[:, 1:].reshape((len(node_list),)) + + # This line guarantees that the median weight is 0.5: + weights = np.exp(np.log(0.5) * dists / (np.median(dists))) + + W = sparse.csc_matrix((weights, (node_list, nbrs)), + shape=(h * w, h * w)) + W = utils.symmetrize(W, 'full') + + super(ImgPatches, self).__init__(W=W, gtype='patch-graph', + perform_all_checks=False, **kwargs) + + self.img = img diff --git a/pygsp/graphs/nngraphs/imgpatchesgrid2d.py b/pygsp/graphs/nngraphs/imgpatchesgrid2d.py new file mode 100644 index 00000000..bf271982 --- /dev/null +++ b/pygsp/graphs/nngraphs/imgpatchesgrid2d.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +from . import ImgPatches +import networkx as nx +import numpy as np + + +class ImgPatchesGrid2d(ImgPatches): + r""" + Create the union of an image patch graph with a 2-dimensional grid graph. + + Parameters + ---------- + img : array + Input image. + patch_shape : tuple, optional + Dimensions of the patch window. Syntax : (height, width). + n_nbrs : int + Number of neighbors to consider + dist_type : string + Type of distance between patches to compute. See + :func:`pyflann.index.set_distance_type` for possible options. + aggregation: callable, optional + Function used for aggregating the weights of the patch graph and the + 2-d grid graph. Default is sum(). + + Examples + -------- + >>> from pygsp.graphs import nngraphs + >>> from skimage import data, img_as_float + >>> img = img_as_float(data.camera()[::2, ::2]) + >>> G = nngraphs.ImgPatchesGrid2d(img) + + """ + + def __init__(self, img, patch_shape, n_nbrs, + aggregation=lambda Wp, Wg: Wp + Wg, **kwargs): + super(ImgPatchesGrid2d, self).__init__(img=img, + patch_shape=patch_shape, + n_nbrs=n_nbrs, + **kwargs) + m, n = self.img.shape + # Grid2d from pygsp is too slow: + # from .. import Grid2d + # Gg = Grid2d(Nv=n, Mv=m) + # Wg = G.Wg + # Use networkx instead: + Gg = nx.grid_2d_graph(m, n) + Wg = nx.to_scipy_sparse_matrix(Gg) # some edges seem to be missing + + self.W = aggregation(self.W, Wg) + + x = np.kron(np.ones((m, 1)), (np.arange(n) / float(n)).reshape(n, 1)) + y = np.kron(np.ones((n, 1)), np.arange(m) / float(m)).reshape(m * n, 1) + y = np.sort(y, axis=0)[::-1] + self.coords = np.concatenate((x, y), axis=1) + self.gtype = self.gtype + '-2d-grid' diff --git a/pygsp/utils.py b/pygsp/utils.py index 9b4611e2..ebe93986 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -13,7 +13,8 @@ def build_logger(name, **kwargs): logging_level = kwargs.pop('logging_level', logging.DEBUG) if not logger.handlers: - formatter = logging.Formatter("%(asctime)s:[%(levelname)s](%(name)s.%(funcName)s): %(message)s") + formatter = logging.Formatter( + "%(asctime)s:[%(levelname)s](%(name)s.%(funcName)s): %(message)s") steam_handler = logging.StreamHandler() steam_handler.setLevel(logging_level) @@ -125,12 +126,12 @@ def distanz(x, y=None): if rx != ry: raise("The sizes of x and y do not fit") - xx = (x*x).sum(axis=0) - yy = (y*y).sum(axis=0) + xx = (x * x).sum(axis=0) + yy = (y * y).sum(axis=0) xy = np.dot(x.T, y) d = abs(kron(ones((cy, 1)), xx).T + - kron(ones((cx, 1)), yy) - 2*xy) + kron(ones((cx, 1)), yy) - 2 * xy) return np.sqrt(d) @@ -182,3 +183,35 @@ def resistance_distance(M): # 1 call dans operators.reduction - pseudo - pseudo.T return rd + + +def symmetrize(W, symmetrize_type='average'): + r""" + Symmetrize a square matrix + + Parameters + ---------- + W : array_like + Square matrix to be symmetrized + symm_type : string + 'average' : symmetrize by averaging with the transpose. + 'full' : symmetrize by filling in the holes in the transpose. + + """ + if W.shape[0] != W.shape[1]: + raise ValueError("Matrix must be square") + + sparse_flag = True if sparse.issparse(W) else False + + if symmetrize_type == 'average': + return (W + W.T) / 2. + elif symmetrize_type == 'full': + A = (W > 0) + if sparse_flag: + mask = ((A + A.T) - A).astype('float') + else: + # numpy boolean subtract is deprecated in python 3 + mask = np.logical_xor(np.logical_or(A, A.T), A).astype('float') + return W + mask.multiply(W.T) if sparse_flag else W + (mask * W.T) + else: + return W diff --git a/setup.py b/setup.py index 3233f850..d091ddd2 100644 --- a/setup.py +++ b/setup.py @@ -20,14 +20,20 @@ author='Alexandre Lafaye, Basile Châtillon, Lionel Martin, Nicolas Rod (EPFL LTS2)', author_email='alexandre.lafaye@epfl.ch, basile.chatillon@epfl.ch, lionel.martin@epfl.ch, nicolas.rod@epfl.ch', url='https://github.com/epfl-lts2/', - packages=['pygsp', 'pygsp.filters', 'pygsp.graphs', 'pygsp.graphs.nngraphs', 'pygsp.operators', + packages=['pygsp', 'pygsp.filters', 'pygsp.graphs', + 'pygsp.graphs.nngraphs', 'pygsp.operators', 'pygsp.pointclouds', 'pygsp.tests'], package_data={'pygsp.pointclouds': ['misc/*.mat']}, test_suite='pygsp.tests.test_all.suite', - dependency_links=['https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-0.10.1fork'], - install_requires=['numpy', 'scipy', 'pyopengl', 'pyqtgraph<=0.10.1', - 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', - 'PyQt5' if sys.version_info.major == 3 and sys.version_info.minor == 5 else 'PySide'], + dependency_links=[ + 'https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-0.10.1fork'], + install_requires=[ + 'numpy', + 'scipy', + 'pyopengl', + 'pyqtgraph<=0.10.1', + 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', + 'PyQt5' if sys.version_info.major == 3 and sys.version_info.minor == 5 else 'PySide'], license="BSD", keywords='graph signal processing toolbox filters pointclouds', platforms='any', From e81210faef5780286d363856247e9b89bb00830a Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 2 Jun 2017 12:45:38 +0100 Subject: [PATCH 004/392] Make pygsp.graphs.Grid2d faster --- .gitignore | 3 ++ pygsp/graphs/grid2d.py | 89 +++++++++++++++++++----------------------- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 2a7b00c7..795bd6da 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ docs/_build # Mac OS garbage .DS_Store + +# Jupyter notebook +.ipynb_checkpoints/ diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 86388ded..f1d89cfa 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -3,6 +3,7 @@ import numpy as np from scipy import sparse from . import Graph +from .. import utils class Grid2d(Graph): @@ -11,62 +12,54 @@ class Grid2d(Graph): Parameters ---------- - Nv : int - Number of vertices along the first dimension (default is 16) - Mv : int - Number of vertices along the second dimension (default is Nv) + shape : tuple + Dimensions of the 2-dimensional grid. Syntax: (height, width), or + (height,), in which case one has width = height. + + Notes + ----- + The total number of nodes on the graph is N = height * width, that is, the + number of point in the grid. Examples -------- >>> from pygsp import graphs - >>> G = graphs.Grid2d(Nv=32) + >>> G = graphs.Grid2d(shape=(32,)) """ - def __init__(self, Nv=16, Mv=None, **kwargs): - if not Mv: - Mv = Nv - - # Create weighted adjacency matrix - K = 2 * (Nv - 1) - J = 2 * (Mv - 1) - - i_inds = np.zeros((K * Mv + J * Nv), dtype=float) - j_inds = np.zeros((K * Mv + J * Nv), dtype=float) - - tmpK = np.arange(K, dtype=int) - tmpNv1 = np.arange(Nv - 1) - for i in range(Mv): - i_inds[i * K + tmpK] = i * Nv + \ - np.concatenate((tmpNv1, tmpNv1 + 1)) - j_inds[i * K + tmpK] = i * Nv + \ - np.concatenate((tmpNv1 + 1, tmpNv1)) - - tmp2Nv = np.arange(2 * Nv, dtype=int) - tmpNv = np.arange(Nv) - for i in range(Mv - 1): - i_inds[(K * Mv) + i * 2 * Nv + tmp2Nv] = \ - np.concatenate((i * Nv + tmpNv, (i + 1) * Nv + tmpNv)) - - j_inds[(K * Mv) + i * 2 * Nv + tmp2Nv] = \ - np.concatenate(((i + 1) * Nv + tmpNv, i * Nv + tmpNv)) - - W = sparse.csc_matrix((np.ones((K * Mv + J * Nv)), (i_inds, j_inds)), - shape=(Mv * Nv, Mv * Nv)) - - xtmp = np.kron(np.ones((Mv, 1)), (np.arange(Nv) / float(Nv)).reshape(Nv, - 1)) - ytmp = np.sort(np.kron(np.ones((Nv, 1)), - np.arange(Mv) / float(Mv)).reshape(Mv * Nv, 1), - axis=0) - - coords = np.concatenate((xtmp, ytmp), axis=1) - - self.Nv = Nv - self.Mv = Mv + def __init__(self, shape=(3,), **kwargs): + # (Rodrigo) I think using a single shape parameter, and calling the + # dimensions of the grid 'height' (h) and 'width' (w) make more sense + # than the previous Nv and Mv. + try: + h, w = shape + except ValueError: + h = shape[0] + w = h + + # Filling up the weight matrix this way is faster than looping through + # all the grid points: + diag_1 = np.ones((h * w - 1,)) + diag_1[(w - 1)::w] = 0 + diag_3 = np.ones((h * w - 3,)) + W = sparse.diags(diagonals=[diag_1, diag_3], + offsets=[-1, -3], + shape=(h * w, h * w), + format='csr', + dtype='float') + W = utils.symmetrize(W, symmetrize_type='full') + + x = np.kron(np.ones((h, 1)), (np.arange(w) / float(w)).reshape(w, 1)) + y = np.kron(np.ones((w, 1)), np.arange(h) / float(h)).reshape(h * w, 1) + y = np.sort(y, axis=0)[::-1] + coords = np.concatenate((x, y), axis=1) + + self.h = h + self.w = w plotting = {"vertex_size": 30, - "limits": np.array([-1. / self.Nv, 1 + 1. / self.Nv, - 1. / self.Mv, 1 + 1. / self.Mv])} + "limits": np.array([-1. / self.w, 1 + 1. / self.w, + 1. / self.h, 1 + 1. / self.h])} super(Grid2d, self).__init__(W=W, gtype='2d-grid', coords=coords, plotting=plotting, **kwargs) From 44541c57a89690bd51fe4c1ebe08275eca7152f8 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 2 Jun 2017 15:06:19 +0100 Subject: [PATCH 005/392] finish implementing img patch graphs --- pygsp/graphs/grid2d.py | 15 +++--- pygsp/graphs/nngraphs/__init__.py | 2 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 44 +++++++++++++++++ pygsp/graphs/nngraphs/imgpatches.py | 32 +++++++++---- pygsp/graphs/nngraphs/imgpatchesgrid2d.py | 57 ----------------------- requirements.txt | 3 ++ 6 files changed, 79 insertions(+), 74 deletions(-) create mode 100644 pygsp/graphs/nngraphs/grid2dimgpatches.py delete mode 100644 pygsp/graphs/nngraphs/imgpatchesgrid2d.py diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index f1d89cfa..07b43dc0 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -32,19 +32,20 @@ def __init__(self, shape=(3,), **kwargs): # (Rodrigo) I think using a single shape parameter, and calling the # dimensions of the grid 'height' (h) and 'width' (w) make more sense # than the previous Nv and Mv. + h = shape[0] try: - h, w = shape + w = shape[1] except ValueError: - h = shape[0] w = h - # Filling up the weight matrix this way is faster than looping through - # all the grid points: + # (Rodrigo) Filling up the weight matrix this way is faster than + # looping through all the grid points: diag_1 = np.ones((h * w - 1,)) diag_1[(w - 1)::w] = 0 - diag_3 = np.ones((h * w - 3,)) - W = sparse.diags(diagonals=[diag_1, diag_3], - offsets=[-1, -3], + stride = w + diag_2 = np.ones((h * w - stride,)) + W = sparse.diags(diagonals=[diag_1, diag_2], + offsets=[-1, -stride], shape=(h * w, h * w), format='csr', dtype='float') diff --git a/pygsp/graphs/nngraphs/__init__.py b/pygsp/graphs/nngraphs/__init__.py index 454a79c6..636c8fa9 100644 --- a/pygsp/graphs/nngraphs/__init__.py +++ b/pygsp/graphs/nngraphs/__init__.py @@ -10,7 +10,7 @@ 'Sphere', 'TwoMoons', 'ImgPatches', - 'ImgPatchesGrid2d'] + 'Grid2dImgPatches'] for class_to_import in __all__: setattr(sys.modules[__name__], diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py new file mode 100644 index 00000000..dc7e59bb --- /dev/null +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +from . import ImgPatches +from .. import Grid2d +from .. import Graph + + +class Grid2dImgPatches(Graph): + r""" + Create the union of an image patch graph with a 2-dimensional grid graph. + + Parameters + ---------- + img : array + Input image. + patch_shape : tuple, optional + Dimensions of the patch window. Syntax : (height, width). + n_nbrs : int + Number of neighbors to consider + dist_type : string + Type of distance between patches to compute. See + :func:`pyflann.index.set_distance_type` for possible options. + aggregate: callable, optional + Function used for aggregating the weights Wp of the patch graph and the + weigths Wg 2d grid graph. Default is :func:`lambda Wp, Wg: Wp + Wg`. + + Examples + -------- + >>> from pygsp.graphs import nngraphs + >>> from skimage import data, img_as_float + >>> img = img_as_float(data.camera()[::32, ::32]) + >>> G = nngraphs.Grid2dImgPatches(img) + + """ + + def __init__(self, img, patch_shape, n_nbrs, + aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): + Gg = Grid2d(shape=img.shape) + Gp = ImgPatches(img=img, patch_shape=patch_shape, n_nbrs=n_nbrs) + super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), + gtype=Gg.gtype + Gp.gtype, + coords=Gg.coords, + plotting=Gg.plotting, + **kwargs) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index d404b649..cea64add 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -17,10 +17,11 @@ class ImgPatches(Graph): img : array Input image. patch_shape : tuple, optional - Dimensions of the patch window. Syntax : (height, width). - n_nbrs : int + Dimensions of the patch window. Syntax: (height, width), or (height,), + in which case width = height. + n_nbrs : int, optional Number of neighbors to consider - dist_type : string + dist_type : string, optional Type of distance between patches to compute. See :func:`pyflann.index.set_distance_type` for possible options. @@ -36,18 +37,31 @@ class ImgPatches(Graph): def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, dist_type='euclidean', **kwargs): try: - h, w, _ = img.shape + h, w, d = img.shape except ValueError: try: h, w = img.shape + d = 1 except ValueError: print("Image should be a 2-d array.") - pad_width = (int((patch_shape[0] - 1) / 2), - int((patch_shape[1] - 1) / 2)) - img_pad = pad(img, pad_width=pad_width, mode='edge') - patches = view_as_windows(img_pad, window_shape=patch_shape) - X = patches.reshape((h * w, patch_shape[0] * patch_shape[1])) + try: + r, c = patch_shape + except ValueError: + r = patch_shape[0] + c = r + if d <= 1: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) + else: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), + (0, 0)) + img_pad = pad(img, pad_width=pad_width, mode='symmetric') + + patches = view_as_windows(img_pad, + window_shape=tuple(np.maximum((r, c, d), 1))) + X = patches.reshape((h * w, r * c * d)) set_distance_type(dist_type) flann = FLANN() diff --git a/pygsp/graphs/nngraphs/imgpatchesgrid2d.py b/pygsp/graphs/nngraphs/imgpatchesgrid2d.py deleted file mode 100644 index bf271982..00000000 --- a/pygsp/graphs/nngraphs/imgpatchesgrid2d.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import ImgPatches -import networkx as nx -import numpy as np - - -class ImgPatchesGrid2d(ImgPatches): - r""" - Create the union of an image patch graph with a 2-dimensional grid graph. - - Parameters - ---------- - img : array - Input image. - patch_shape : tuple, optional - Dimensions of the patch window. Syntax : (height, width). - n_nbrs : int - Number of neighbors to consider - dist_type : string - Type of distance between patches to compute. See - :func:`pyflann.index.set_distance_type` for possible options. - aggregation: callable, optional - Function used for aggregating the weights of the patch graph and the - 2-d grid graph. Default is sum(). - - Examples - -------- - >>> from pygsp.graphs import nngraphs - >>> from skimage import data, img_as_float - >>> img = img_as_float(data.camera()[::2, ::2]) - >>> G = nngraphs.ImgPatchesGrid2d(img) - - """ - - def __init__(self, img, patch_shape, n_nbrs, - aggregation=lambda Wp, Wg: Wp + Wg, **kwargs): - super(ImgPatchesGrid2d, self).__init__(img=img, - patch_shape=patch_shape, - n_nbrs=n_nbrs, - **kwargs) - m, n = self.img.shape - # Grid2d from pygsp is too slow: - # from .. import Grid2d - # Gg = Grid2d(Nv=n, Mv=m) - # Wg = G.Wg - # Use networkx instead: - Gg = nx.grid_2d_graph(m, n) - Wg = nx.to_scipy_sparse_matrix(Gg) # some edges seem to be missing - - self.W = aggregation(self.W, Wg) - - x = np.kron(np.ones((m, 1)), (np.arange(n) / float(n)).reshape(n, 1)) - y = np.kron(np.ones((n, 1)), np.arange(m) / float(m)).reshape(m * n, 1) - y = np.sort(y, axis=0)[::-1] - self.coords = np.concatenate((x, y), axis=1) - self.gtype = self.gtype + '-2d-grid' diff --git a/requirements.txt b/requirements.txt index 220364cc..2cec6b78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,6 @@ git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev # Additional development requirements # -r dev_requirements.txt + +# Required for efficiently dealing with images +skimage \ No newline at end of file From d2962b5159a5e1517cdcaa4b21982e967746c8a2 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 2 Jun 2017 15:09:54 +0100 Subject: [PATCH 006/392] solve skimage requirement issue --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2cec6b78..c84845d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,4 @@ git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev # -r dev_requirements.txt # Required for efficiently dealing with images -skimage \ No newline at end of file +scikit-image \ No newline at end of file From 6c5dc78bf57858ec8c36ca7f847ede25dfc91fc3 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 2 Jun 2017 15:12:54 +0100 Subject: [PATCH 007/392] solve pyflann requirement issue --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c84845d0..e57f3642 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev # -r dev_requirements.txt # Required for efficiently dealing with images -scikit-image \ No newline at end of file +scikit-image +pyflann \ No newline at end of file From f6d7682c335a2bb7fda29e2cd7137949382726a7 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 2 Jun 2017 15:30:58 +0100 Subject: [PATCH 008/392] restore Graph.is_directed() call inside Graph.is_connected() --- 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 8156f7ab..78fc7995 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -88,8 +88,8 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.gtype = gtype self.lap_type = lap_type - # (Rodrigo): This check was inside self.is_connected(), but they should - # be independent of each other. + # (Rodrigo): This check in only inside self.is_connected(), but I + # think they should be independent of each other. if not hasattr(self, 'directed'): self.is_directed() @@ -370,6 +370,9 @@ def is_connected(self, force_recompute=False): "Connectivity for this graph is already known. Stopping.") return self.connected + if not hasattr(self, 'directed'): + self.is_directed() + if self.A.shape[0] != self.A.shape[1]: self.logger.error( 'Inconsistant shape to test connectedness. Set to False.') From f0aaaedad0cc7942730ba74e0827e4ac8b8cbd79 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Tue, 6 Jun 2017 16:22:17 +0100 Subject: [PATCH 009/392] Improve formatting and documentation --- pygsp/graphs/graph.py | 170 +++++++++++++++++++++++------------------ pygsp/graphs/grid2d.py | 24 +++--- 2 files changed, 108 insertions(+), 86 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 78fc7995..6c758f6d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -14,34 +14,43 @@ class Graph(object): r""" The main graph object. - It is used to initialize by default every missing field of the subclass graphs. - It can also be used alone to initialize customs graphs. + It is used to initialize by default every missing field of the subclass + graphs. It can also be used by itself to initialize customs graphs. **Fields**: A graph contains the following fields: - - N : the number of nodes (also called vertices sometimes) in the graph. - They represent the different points between which connections may occur. + - N : the number of nodes (also called vertices sometimes) in the + graph. + They represent the different points between which connections may + occur. - Ne : the number of edges (also called links sometimes) in the graph. They represent the actual connections between the nodes. - W : the weight matrix contains the weights of the connections. - It is represented as a NxN matrix of floats. W_i,j = 0 means that there is no connection from i to j. + 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. - A : the adjacency matrix defines which edges exist on the graph. - It is represented as a NxN matrix of booleans. A_i,j is True if W_i,j > 0. + It is represented as an N-by-N matrix of booleans. + :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. - d : the degree vector of the vertices. - It is represented as a Nx1 vector counting the number of connections that each node possesses. + It is represented as a Nx1 vector counting the number of + connections that each node possesses. - gtype : the graph type is a short description of the graph object. It is a string designed to help sorting the graphs - directed : the flag to assess if the graph is directed or not. - In this framework, we consider that a graph is directed if and only if its weight matrix is non symmetric. - - L : the laplacian matrix. - It is represented as a NxN matrix computed from W. - - lap_type : the laplacian type determine which kind of laplacian to compute. - From a given matrix W, there exist several laplacians that could be computed. - - coords : the coordinates of the vertices in the 2D or 3D space for plotting. - The default is None + In this framework, we consider that a graph is directed if and + only if its weight matrix is non symmetric. + - L : the graph Laplacian matrix. + It is represented as an N-by-N matrix computed from W. + - lap_type : string that determines which kind of laplacian to compute. + From a given matrix W, there exist several Laplacians that could + be computed. + - coords : the coordinates of the vertices in the 2D or 3D space for + plotting. + The default is None. - plotting : all the plotting parameters go here. They depend on the library used for plotting. @@ -157,19 +166,22 @@ def update_graph_attr(self, *args, **kwargs): if i in valid_attributes: graph_attr[i] = getattr(self, i) else: - self.logger.warning( - 'Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) + self.logger.warning(('Your attribute {} does not figure in ' + 'the valid attributes, which are ' + '{}').format(i, valid_attributes)) for i in kwargs: if i in valid_attributes: if i in graph_attr: - self.logger.info( - 'You already give this attribute in the args. Therefore, it will not be recaculate.') + self.logger.info('You already gave this attribute as ' + 'an argument. Therefore, it will not ' + 'be recomputed.') else: graph_attr[i] = kwargs[i] else: - self.logger.warning( - 'Your attribute {} do not figure is the valid_attributes who are {}'.format(i, valid_attributes)) + self.logger.warning(('Your attribute {} does not figure in ' + 'the valid attributes, which are ' + '{}').format(i, valid_attributes)) from nngraphs import NNGraph if isinstance(self, NNGraph): @@ -224,7 +236,8 @@ def set_coords(self, kind='spring', **kwargs): ---------- kind : string The kind of display. Default is 'spring'. - Accepting ['community2D', 'manual', 'random2D', 'random3D', 'ring2D', 'spring']. + Accepting ['community2D', 'manual', 'random2D', 'random3D', + 'ring2D', 'spring']. coords : np.ndarray An array of coordinates in 2D or 3D. Used only if kind is manual. Set the coordinates to this array as is. @@ -237,7 +250,8 @@ def set_coords(self, kind='spring', **kwargs): >>> G.plot() """ - if kind not in ['community2D', 'manual', 'random2D', 'random3D', 'ring2D', 'spring']: + if kind not in ['community2D', 'manual', 'random2D', 'random3D', + 'ring2D', 'spring']: raise ValueError('Unexpected kind argument. Got {}.'.format(kind)) if kind == 'manual': @@ -248,8 +262,8 @@ def set_coords(self, kind='spring', **kwargs): coords.shape[0] == self.N and 2 <= coords.shape[1] <= 3: self.coords = coords else: - raise ValueError( - 'Expecting coords to be a list or ndarray of size Nx2 or Nx3.') + raise ValueError('Expecting coords to be a list or ndarray ' + 'of size Nx2 or Nx3.') elif kind == 'ring2D': tmp = np.arange(self.N).reshape(self.N, 1) @@ -268,35 +282,37 @@ def set_coords(self, kind='spring', **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.') + 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())]) + 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)))) + 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)))) - # nodes' coordinates inside the community + # 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]) + 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 + # 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] + self.coords[i] = self.info['com_coords'][comm_idx] + \ + comm_rad * self.coords[i] def subgraph(self, ind): r""" @@ -325,7 +341,7 @@ def subgraph(self, ind): 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) + # N = len(ind) # Assigned but never used sub_W = self.W.tocsr()[ind, :].tocsc()[:, ind] return Graph(sub_W, gtype="sub-{}".format(self.gtype)) @@ -363,19 +379,19 @@ def is_connected(self, force_recompute=False): """ if hasattr(self, 'force_recompute'): if force_recompute: - self.logger.warning( - "Connectivity for this graph is already known. Recomputing.") + self.logger.warning("Connectivity for this graph is already " + "known. Recomputing.") else: - self.logger.error( - "Connectivity for this graph is already known. Stopping.") + self.logger.error("Connectivity for this graph is already " + "known. Stopping.") return self.connected if not hasattr(self, 'directed'): self.is_directed() if self.A.shape[0] != self.A.shape[1]: - self.logger.error( - 'Inconsistant shape to test connectedness. Set to False.') + self.logger.error("Inconsistent shape to test connectedness. " + "Set to False.") self.connected = False return False @@ -390,8 +406,9 @@ def is_connected(self, force_recompute=False): # 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]])) + stack.update(set([idx + for idx in adj_matrix[v, :].nonzero()[1] + if not visited[idx]])) if not visited.all(): self.connected = False @@ -424,16 +441,16 @@ def is_directed(self, force_recompute=False): """ if hasattr(self, 'force_recompute'): if force_recompute: - self.logger.warning("Directedness for this graph is already known.\ - Recomputing.") + self.logger.warning("Directedness for this graph is already " + "known. Recomputing.") else: - self.logger.error("Directedness for this graph is already known.\ - Stopping.") + self.logger.error("Directedness for this graph is already " + "known. Stopping.") return self.directed if np.diff(np.shape(self.W))[0]: - raise ValueError( - "Matrix dimensions mismatch, expecting square matrix.") + raise ValueError("Matrix dimensions mismatch, expecting square " + "matrix.") is_dir = np.abs(self.W - self.W.T).sum() != 0 @@ -445,13 +462,16 @@ def extract_components(self): r""" Split the graph into several connected components. - See the doc of `is_connected` for the method used to determine connectedness. + See the doc of `is_connected` for the method used to determine + connectedness. Returns ------- graphs : list - A list of graph structures. Each having its own node list and weight matrix. - If the graph is directed, add into the info parameter the information about the source nodes and the sink nodes. + A list of graph structures. Each having its own node list and + weight matrix. If the graph is directed, add into the info + parameter the information about the source nodes and the sink + nodes. Examples -------- @@ -468,8 +488,8 @@ def extract_components(self): self.is_directed() if self.A.shape[0] != self.A.shape[1]: - self.logger.error( - 'Inconsistant shape to extract components. Square matrix required.') + self.logger.error('Inconsistant shape to extract components. ' + 'Square matrix required.') return None if self.directed: @@ -478,10 +498,10 @@ def extract_components(self): graphs = [] visited = np.zeros(self.A.shape[0], dtype=bool) - indices = [] + # indices = [] # Assigned but never used while not visited.all(): - stack = set([np.nonzero(visited == False)[0][0]]) + stack = set([np.nonzero(visited is False)[0][0]]) comp = [] while len(stack): @@ -492,12 +512,12 @@ def extract_components(self): # Add indices of nodes not visited yet and accessible from # v - stack.update( - set([idx for idx in self.A[v, :].nonzero()[1] if not visited[idx]])) + stack.update(set([idx for idx in self.A[v, :].nonzero()[1] + if not visited[idx]])) comp = sorted(comp) - self.logger.info( - 'Constructing subgraph for component of size {}.'.format(len(comp))) + self.logger.info(('Constructing subgraph for component of ' + 'size {}.').format(len(comp))) G = self.subgraph(comp) G.info = {'orig_idx': comp} graphs.append(G) @@ -548,17 +568,16 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, """ if hasattr(self, 'e') or hasattr(self, 'U'): if force_recompute: - self.logger.warning( - "This graph already has a Fourier basis. Recomputing.") + self.logger.warning("This graph already has a Fourier basis." + " Recomputing.") else: - self.logger.error( - "This graph already has a Fourier basis. Stopping.") + self.logger.error("This graph already has a Fourier basis. " + "Stopping.") return if self.N > 3000: - self.logger.warning( - "Performing full eigendecomposition of a large " - "matrix may take some time.") + self.logger.warning("Performing full eigendecomposition of a " + "large matrix may take some time.") if not hasattr(self, 'L'): raise AttributeError("Graph Laplacian is missing") @@ -598,7 +617,8 @@ def create_laplacian(self, lap_type='combinatorial'): if self.directed: if lap_type == 'combinatorial': L = 0.5 * (sparse.diags(np.ravel(self.W.sum(0)), 0) + - sparse.diags(np.ravel(self.W.sum(1)), 0) - self.W - self.W.T).tocsc() + sparse.diags(np.ravel(self.W.sum(1)), 0) - + self.W - self.W.T).tocsc() elif lap_type == 'normalized': raise NotImplementedError('Yet. Ask Nathanael.') elif lap_type == 'none': @@ -644,13 +664,13 @@ def estimate_lmax(self, force_recompute=False): return try: - # On robustness purposes, increasing the error by 1 percent + # For robustness purposes, increase the error by 1 percent lmax = 1.01 * \ sparse.linalg.eigs(self.L, k=1, tol=5e-3, ncv=10)[0][0] except sparse.linalg.ArpackNoConvergence: - self.logger.warning( - 'GSP_ESTIMATE_LMAX: Cannot use default method.') + self.logger.warning('GSP_ESTIMATE_LMAX: ' + 'Cannot use default method.') lmax = 2. * np.max(self.d) lmax = np.real(lmax) @@ -725,8 +745,8 @@ def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, # make sure we have a LIst of Lists representation try: A = A.tolil() - except: - A = (coo_matrix(A)).tolil() + except Exception: + A = (sparse.coo_matrix(A)).tolil() if pos is None: # random initial positions diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 07b43dc0..6a92853c 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -12,33 +12,35 @@ class Grid2d(Graph): Parameters ---------- - shape : tuple - Dimensions of the 2-dimensional grid. Syntax: (height, width), or - (height,), in which case one has width = height. + shape : int or tuple, optional + Dimensions of the 2-dimensional grid. Syntax: (height, width), + (height,), or height, where the last two options imply width = height. + Default is shape = (3,). Notes ----- The total number of nodes on the graph is N = height * width, that is, the - number of point in the grid. + number of points in the grid. Examples -------- >>> from pygsp import graphs - >>> G = graphs.Grid2d(shape=(32,)) + >>> G = graphs.Grid2d(shape=(32,) """ def __init__(self, shape=(3,), **kwargs): - # (Rodrigo) I think using a single shape parameter, and calling the - # dimensions of the grid 'height' (h) and 'width' (w) make more sense - # than the previous Nv and Mv. - h = shape[0] + # Parse shape try: - w = shape[1] + h, w = shape except ValueError: + h = shape[0] + w = h + except TypeError: + h = shape w = h - # (Rodrigo) Filling up the weight matrix this way is faster than + # Filling up the weight matrix this way is faster than # looping through all the grid points: diag_1 = np.ones((h * w - 1,)) diag_1[(w - 1)::w] = 0 From cceee91ae0b3da58d51426e52d52e1083aa278d4 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Tue, 6 Jun 2017 17:48:32 +0100 Subject: [PATCH 010/392] solve ambiguity in utils.symmetrize() when symmetrize_type='full' --- pygsp/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index ebe93986..6b7f56d8 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -193,7 +193,7 @@ def symmetrize(W, symmetrize_type='average'): ---------- W : array_like Square matrix to be symmetrized - symm_type : string + symmetrize_type : string 'average' : symmetrize by averaging with the transpose. 'full' : symmetrize by filling in the holes in the transpose. @@ -212,6 +212,7 @@ def symmetrize(W, symmetrize_type='average'): else: # numpy boolean subtract is deprecated in python 3 mask = np.logical_xor(np.logical_or(A, A.T), A).astype('float') - return W + mask.multiply(W.T) if sparse_flag else W + (mask * W.T) + W += mask.multiply(W.T) if sparse_flag else (mask * W.T) + return (W + W.T) / 2. # Resolve ambiguous entries else: return W From 1a1bff6666ae5d92529da4b2346fa54baebacbce Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Wed, 21 Jun 2017 23:19:31 +0200 Subject: [PATCH 011/392] make imgpatches graph an instance of nngraphs; implement flann on nngraphs --- pygsp/__init__.py | 28 +++-- pygsp/features.py | 86 +++++++++++-- pygsp/filters/filter.py | 43 ++++--- pygsp/graphs/graph.py | 2 +- pygsp/graphs/grid2d.py | 3 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 8 +- pygsp/graphs/nngraphs/imgpatches.py | 68 ++--------- pygsp/graphs/nngraphs/nngraph.py | 141 +++++++++++++--------- pygsp/operators/operator.py | 47 +++++--- pygsp/utils.py | 9 +- 10 files changed, 267 insertions(+), 168 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 6850fd62..a8f53b45 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -1,23 +1,33 @@ # -*- coding: utf-8 -*- """ -This toolbox is splitted in different modules taking care of the different aspects of Graph Signal Processing. +This toolbox is splitted in different modules taking care of the different +aspects of Graph Signal Processing. -Those modules are : :ref:`Graphs `, :ref:`Filters `, :ref:`Operators `, :ref:`PointCloud `, :ref:`Plotting `, :ref:`Data Handling ` and :ref:`Utils `. +Those modules are : :ref:`Graphs `, :ref:`Filters `, +:ref:`Operators `, :ref:`PointCloud `, +:ref:`Plotting `, :ref:`Data Handling ` and +:ref:`Utils `. -You can find detailed documentation on the use of the functions in the subsequent pages. +You can find detailed documentation on the use of the functions in the +subsequent pages. """ # When importing the toolbox, you surely want these modules. -from pygsp import graphs -from pygsp import operators -from pygsp import utils -from pygsp import features -from pygsp import filters -from pygsp import pointclouds from pygsp import data_handling from pygsp import optimization from pygsp import plotting +from pygsp import utils +from pygsp import filters +from pygsp import graphs + +# Module features have to be imported after graphs and filters, otherwise we +# get a circular dependency error. +from pygsp import features + +from pygsp import operators +from pygsp import pointclouds + # Silence the code checker warning about unused symbols. assert data_handling diff --git a/pygsp/features.py b/pygsp/features.py index e8548769..1923ce44 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -1,19 +1,22 @@ # -*- coding: utf-8 -*- -r"""This module implements different feature extraction techniques based on Graphs and Filters of the GSP box.""" +r""" +This module implements different feature extraction techniques based on +Graphs and Filters of the GSP box. +""" import numpy as np -import scipy as sp -from scipy import sparse from .graphs import Graph from .filters import Filter from .utils import filterbank_handler +from skimage.util import view_as_windows, pad def compute_avg_adj_deg(G): r""" Compute the average adjacency degree for each node. - Average adjacency degree is the average of the degrees of a node and its neighbors. + Average adjacency degree is the average of the degrees of a node and its + neighbors. Parameters ---------- @@ -71,7 +74,8 @@ def compute_norm_tig(filt, method=None, *args, **kwargs): def compute_spectrogramm(G, atom=None, M=100, method=None, **kwargs): r""" - Compute the norm of the Tig for all nodes with a kernel shifted along the spectral axis. + Compute the norm of the Tig for all nodes with a kernel shifted along the + spectral axis. Parameters ---------- @@ -90,14 +94,82 @@ def compute_spectrogramm(G, atom=None, M=100, method=None, **kwargs): if not atom or not hasattr(atom, '__call__'): def atom(x): - return np.exp(-M * (x/G.lmax)**2) + return np.exp(-M * (x / G.lmax)**2) scale = np.linspace(0, G.lmax, M) spectr = np.zeros((G.N, M)) for shift_idx in range(M): - shft_filter = Filter(G, filters=[lambda x: atom(x-scale[shift_idx])], **kwargs) + shft_filter = Filter(G, + filters=[lambda x: atom(x - scale[shift_idx])], + **kwargs) spectr[:, shift_idx] = compute_norm_tig(shft_filter, method=method)**2 G.spectr = spectr return spectr + + +def patch_features(img, patch_shape=(3, 3)): + r""" + Compute a patch feature vector for every pixel of an image. + + Parameters + ---------- + img : array + Input image. + patch_shape : tuple, optional + Dimensions of the patch window. Syntax: (height, width), or (height,), + in which case width = height. + + Returns + ------- + array + Feature matrix. + + Notes + ----- + The feature vector of a pixel `i` will consist of the stacking of the + intensity values of all pixels in the patch centered at `i`, for all color + channels. So, if the input image has `d` color channels, the dimension of + the feature vector of each pixel is (patch_shape[0] * patch_shape[1] * d). + + Examples + -------- + >>> from pygsp import features + >>> from skimage import data, img_as_float + >>> img = img_as_float(data.camera()[::2, ::2]) + >>> X = features.patch_features(img) + + """ + + try: + h, w, d = img.shape + except ValueError: + try: + h, w = img.shape + d = 0 + except ValueError: + print("Image should be at least a 2-d array.") + + try: + r, c = patch_shape + except ValueError: + r = patch_shape[0] + c = r + if d == 0: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) + window_shape = (r, c) + d = 1 # For the reshape in the return call + else: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), + (0, 0)) + window_shape = (r, c, d) + # Pad the image + img_pad = pad(img, pad_width=pad_width, mode='symmetric') + + # Extract patches + patches = view_as_windows(img_pad, window_shape=window_shape) + + return patches.reshape((h * w, r * c * d)) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 53490aff..3d365a2b 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -10,7 +10,8 @@ class Filter(object): r""" - Parent class for all Filters or Filterbanks, contains the shared methods for those classes. + Parent class for all Filters or Filterbanks, contains the shared methods + for those classes. """ def __init__(self, G, filters=None, **kwargs): @@ -32,7 +33,8 @@ def __init__(self, G, filters=None, **kwargs): else: self.g = [] - def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): + def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, + **kwargs): r""" Operator to analyse a filterbank @@ -41,7 +43,8 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): s : ndarray graph signals to analyse method : string - whether using an exact method or cheby approx (lanczos not working now) + whether using an exact method or cheby approx (lanczos not working + now) cheb_order : int Order for chebyshev @@ -66,7 +69,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): """ if not method: method = 'exact' if hasattr(self.G, 'U') else 'cheby' - self.logger.info('The analysis method is {}'.format(method)) + self.logger.info('The analysis method is {}'.format(method)) if method == 'cheby': # Chebyshev approx if not hasattr(self.G, 'lmax'): @@ -108,12 +111,13 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): tmpN = np.arange(N, dtype=int) for i in range(Nf): if is2d: - c[tmpN + N*i] =\ + c[tmpN + N * i] = \ operator.igft(self.G, np.tile(fie[i], (Ns, 1)).T * operator.gft(self.G, s)) else: - c[tmpN + N*i] = operator.igft(self.G, fie[i] * - operator.gft(self.G, s)) + c[tmpN + N * i] = \ + operator.igft(self.G, fie[i] * + operator.gft(self.G, s)) else: raise ValueError('Unknown method: please select exact, ' @@ -215,7 +219,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): for i in range(Nf): s += operator.igft(np.conjugate(self.G.U), np.tile(fie[:][i], (Nv, 1)).T * - operator.gft(self.G, c[N*i + tmpN])) + operator.gft(self.G, c[N * i + tmpN])) elif method == 'cheby': if hasattr(self.G, 'lmax'): @@ -223,19 +227,22 @@ def synthesis(self, c, order=30, method=None, **kwargs): 'The function will compute it for you.') self.G.estimate_lmax() - cheb_coeffs = fast_filtering.compute_cheby_coeff(self, m=order, N=order + 1) + cheb_coeffs = fast_filtering.compute_cheby_coeff( + self, m=order, N=order + 1) s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) for i in range(Nf): - s = s + operator.cheby_op(self.G, cheb_coeffs[i], c[i*N + tmpN]) + s = s + operator.cheby_op(self.G, + cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) for i in range(Nf): - s += fast_filtering.lanczos_op(self.G, self.g[i], c[i*N + tmpN], + s += fast_filtering.lanczos_op(self.G, self.g[i], + c[i * N + tmpN], order=order) else: @@ -285,7 +292,8 @@ def filterbank_bounds(self, N=999, bounds=None): self.G.estimate_lmax() if not hasattr(self.G, 'e'): - self.logger.info('FILTERBANK_BOUNDS: Has to compute Fourier basis.') + self.logger.info( + 'FILTERBANK_BOUNDS: Has to compute Fourier basis.') self.G.compute_fourier_basis() rng = self.G.e @@ -309,7 +317,8 @@ def filterbank_matrix(self): N = self.G.N if N > 2000: - self.logger.warning('Creating a big matrix, you can use other methods.') + self.logger.warning( + 'Creating a big matrix, you can use other methods.') Nf = len(self.g) Ft = self.analysis(np.identity(N)) @@ -317,7 +326,7 @@ def filterbank_matrix(self): tmpN = np.arange(N, dtype=int) for i in range(Nf): - F[:, N*i + tmpN] = Ft[N*i + tmpN] + F[:, N * i + tmpN] = Ft[N * i + tmpN] return F @@ -340,8 +349,8 @@ def wlog_scales(self, lmin, lmax, Nscales, t1=1, t2=2): Scale """ - smin = t1/lmax - smax = t2/lmin + smin = t1 / lmax + smax = t2 / lmin s = np.exp(np.linspace(log(smax), log(smin), Nscales)) @@ -360,7 +369,7 @@ def can_dual_func(g, n, x): s = np.zeros((N, M)) for i in range(N): - s[i] = np.linalg.pinv(np.expand_dims(gcoeff[i], axis=1)) + s[i] = np.linalg.pinv(np.expand_dims(gcoeff[i], axis=1)) ret = s[:, n] return ret diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 6c758f6d..c59a1461 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -97,7 +97,7 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.gtype = gtype self.lap_type = lap_type - # (Rodrigo): This check in only inside self.is_connected(), but I + # (Rodrigo): This check is only inside self.is_connected(), but I # think they should be independent of each other. if not hasattr(self, 'directed'): self.is_directed() diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 6a92853c..88a2087b 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -60,8 +60,7 @@ def __init__(self, shape=(3,), **kwargs): self.h = h self.w = w - plotting = {"vertex_size": 30, - "limits": np.array([-1. / self.w, 1 + 1. / self.w, + plotting = {"limits": np.array([-1. / self.w, 1 + 1. / self.w, 1. / self.h, 1 + 1. / self.h])} super(Grid2d, self).__init__(W=W, gtype='2d-grid', coords=coords, diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index dc7e59bb..a110b66f 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -2,7 +2,7 @@ from . import ImgPatches from .. import Grid2d -from .. import Graph +from ..graph import Graph class Grid2dImgPatches(Graph): @@ -33,12 +33,14 @@ class Grid2dImgPatches(Graph): """ - def __init__(self, img, patch_shape, n_nbrs, + def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): Gg = Grid2d(shape=img.shape) Gp = ImgPatches(img=img, patch_shape=patch_shape, n_nbrs=n_nbrs) + gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), - gtype=Gg.gtype + Gp.gtype, + gtype=gtype, coords=Gg.coords, plotting=Gg.plotting, + perform_all_checks=False, **kwargs) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index cea64add..b9cdbb2d 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,14 +1,10 @@ # -*- coding: utf-8 -*- -import numpy as np -from scipy import sparse -from skimage.util import view_as_windows, pad -from pyflann import * -from .. import Graph -from ... import utils +from . import NNGraph +from ...features import patch_features -class ImgPatches(Graph): +class ImgPatches(NNGraph): r""" Create a nearest neighbors graph from patches of an image. @@ -34,54 +30,16 @@ class ImgPatches(Graph): """ - def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, - dist_type='euclidean', **kwargs): - try: - h, w, d = img.shape - except ValueError: - try: - h, w = img.shape - d = 1 - except ValueError: - print("Image should be a 2-d array.") + def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, use_flann=True, + dist_type='euclidean', symmetrize_type='full', **kwargs): - try: - r, c = patch_shape - except ValueError: - r = patch_shape[0] - c = r - if d <= 1: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) - else: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), - (0, 0)) - img_pad = pad(img, pad_width=pad_width, mode='symmetric') - - patches = view_as_windows(img_pad, - window_shape=tuple(np.maximum((r, c, d), 1))) - X = patches.reshape((h * w, r * c * d)) - - set_distance_type(dist_type) - flann = FLANN() - nbrs, dists = flann.nn(X, X, num_neighbors=(n_nbrs + 1), - algorithm="kmeans", branching=32, iterations=7, - checks=16) - - node_list = [[i] * n_nbrs for i in range(h * w)] - node_list = [item for sublist in node_list for item in sublist] - nbrs = nbrs[:, 1:].reshape((len(node_list),)) - dists = dists[:, 1:].reshape((len(node_list),)) - - # This line guarantees that the median weight is 0.5: - weights = np.exp(np.log(0.5) * dists / (np.median(dists))) - - W = sparse.csc_matrix((weights, (node_list, nbrs)), - shape=(h * w, h * w)) - W = utils.symmetrize(W, 'full') - - super(ImgPatches, self).__init__(W=W, gtype='patch-graph', - perform_all_checks=False, **kwargs) + X = patch_features(img, patch_shape=patch_shape) + super(ImgPatches, self).__init__(X, + use_flann=use_flann, + symmetrize_type=symmetrize_type, + dist_type=dist_type, + gtype='patch-graph', + perform_all_checks=False, + **kwargs) self.img = img diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3602516e..ff47ce01 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- -from .. import Graph -from .. import gutils - import numpy as np + +from .. import Graph +from ...utils import symmetrize +from pyflann import * from scipy import sparse, spatial @@ -14,23 +15,42 @@ class NNGraph(Graph): Parameters ---------- Xin : ndarray - Input points - use_flann : bool - Whether flann method should be used (knn is otherwise used). + 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' : k-Nearest Neighbors + 'radius' : epsilon-Nearest Neighbors + (default is 'knn') + use_flann : bool, optional + Use Fast Library for Approximate Nearest Neighbors (FLANN) or not. (default is False) - (this option is not implemented yet) - center : bool - Center the data (default is True) - rescale : bool - Rescale the data (in a 1-ball) (default is True) - k : int + center : bool, optional + Center the data so that it has zero mean (default is True) + 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 - Variance of the distance kernel (default is 0.1) - epsilon : float - RRdius for the range search (default is 0.01) - gtype : string - The type of graph (default is "knn") + sigma : float, optional + Width parameter of the similarity kernel (default is 0.1) + epsilon : float, optional + Radius for the epsilon-neighborhood search (default is 0.01) + gtype : string, optional + The type of graph (default is 'nearest neighbors') + 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') + order : float, optional + Only used if dist_type is 'minkowski'; represents the order of the + Minkowski distance. (default is 0) Examples -------- @@ -43,7 +63,8 @@ class NNGraph(Graph): def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, rescale=True, k=10, sigma=0.1, epsilon=0.01, gtype=None, - plotting={}, symmetrize_type='average', **kwargs): + plotting={}, symmetrize_type='average', dist_type='euclidean', + order=0, **kwargs): if Xin is None: raise ValueError('You must enter a Xin to process the NNgraph') @@ -73,38 +94,51 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, np.mean(self.Xin, axis=0)) 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. + 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 + # 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)) + spi = np.zeros((N * k)) + spj = np.zeros((N * k)) + spv = np.zeros((N * k)) - # since we didn't find a good flann python library yet, we wont implement it for now if self.use_flann: - raise NotImplementedError('Suitable library for flann has not ' - 'been found yet.') + """ + I don't know if the parameters here are optimized for the + trade-off between accuracy and speed. I simply copied the + parameters used in the python example in FLANN's User Manual. + """ + set_distance_type(dist_type, order=order) + flann = FLANN() + NN, D = flann.nn(Xout, Xout, num_neighbors=(k + 1), + algorithm="kmeans", branching=32, + iterations=7, checks=16) + else: kdt = spatial.KDTree(Xout) - D, NN = kdt.query(Xout, k=k + 1) + D, NN = kdt.query(Xout, k=(k + 1), + p=dist_translation[dist_type]) 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)) - - W = sparse.csc_matrix((spv, (spi, spj)), - shape=(np.shape(self.Xin)[0], - np.shape(self.Xin)[0])) + 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': kdt = spatial.KDTree(Xout) - D, NN = kdt.query(Xout, k=None, distance_upper_bound=epsilon) + 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]) @@ -122,32 +156,31 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, float(self.sigma)) start = start + leng - W = sparse.csc_matrix((spv, (spi, spj)), - shape=(np.shape(self.Xin)[0], - np.shape(self.Xin)[0])) - else: raise ValueError('Unknown type : allowed values are knn, radius') + """ + Before, we were calling this same snippet in each of the conditional + statements above, so it's better to call it only once, after the + conditional statements have been evaluated. + """ + 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') - # Symmetry checks + # Enforce symmetry + """ + This conditional statement costs the same amount of computation as the + symmetrization itself, so it's better to simply call utils.symmetrize + no matter what if np.abs(W - W.T).sum(): - if symmetrize_type == 'average': - W = (W + W.T) / 2. - - elif symmetrize_type == 'full': - A = W > 0 - M = (A - (A.T * A)) - W = sparse.csr_matrix(W.T) - W[M.T] = W.T[M.T] - - else: - raise ValueError("Unknown symmetrize type.") + W = utils.symmetrize(W, symmetrize_type=symmetrize_type) else: pass + """ + W = symmetrize(W, symmetrize_type=symmetrize_type) super(NNGraph, self).__init__(W=W, gtype=gtype, plotting=plotting, coords=Xout, **kwargs) diff --git a/pygsp/operators/operator.py b/pygsp/operators/operator.py index c2c4a7d2..d8bf6182 100644 --- a/pygsp/operators/operator.py +++ b/pygsp/operators/operator.py @@ -26,12 +26,8 @@ def div(G, s): The graph divergence """ - if hasattr(G, 'lap_type'): - if G.lap_type == 'combinatorial': - raise NotImplementedError('Not implemented yet. However ask Nathanael it is very easy.') - if G.Ne != np.shape(s)[0]: - raise ValueError('Signal size not equal to number of edges.') + raise ValueError('Signal size is different from the number of edges.') D = grad_mat(G) di = D.T * s @@ -66,9 +62,8 @@ def grad(G, s): Gradient living on the edges """ - if hasattr(G, 'lap_type'): - if G.lap_type == 'combinatorial': - raise NotImplementedError('Not implemented yet. However ask Nathanael it is very easy.') + if G.N != np.shape(s)[0]: + raise ValueError('Signal size is different from the number of nodes.') D = grad_mat(G) gr = D * s @@ -103,18 +98,33 @@ def grad_mat(G): # 1 call (above) adj2vec(G) if hasattr(G, 'Diff'): + if not sparse.issparse(G.Diff): + G.Diff = sparse.csc_matrix(G.Diff) D = G.Diff else: n = G.Ne - Dc = np.ones((2 * n)) - Dv = np.ones((2 * n)) - Dr = np.concatenate((np.arange(n), np.arange(n))) + Dc = np.ones((2 * n)) Dc[:n] = G.v_in Dc[n:] = G.v_out - Dv[:n] = np.sqrt(G.weights.toarray()) - Dv[n:] = -Dv[:n] + Dv = np.ones((2 * n)) + + try: + if G.lap_type == 'combinatorial': + Dv[:n] = np.sqrt(G.weights.toarray()) + Dv[n:] = -Dv[:n] + + elif G.lap_type == 'normalized': + Dv[:n] = np.sqrt(G.weights.toarray() / G.d[G.v_in]) + Dv[n:] = -np.sqrt(G.weights.toarray() / G.d[G.v_out]) + + else: + raise NotImplementedError('grad not implemented yet for ' + + 'this type of graph Laplacian.') + except AttributeError as err: + print('Graph does not have lap_type attribute: ' + str(err)) + D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, G.N)) G.Diff = D @@ -141,7 +151,8 @@ def gft(G, f): if isinstance(G, Graph): if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues and the eigenvectors.') + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') G.compute_fourier_basis() U = G.U @@ -172,7 +183,8 @@ def igft(G, f_hat): if isinstance(G, Graph): if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues and the eigenvectors.') + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') G.compute_fourier_basis() U = G.U @@ -227,7 +239,8 @@ def modulate(G, f, k): """ nt = np.shape(f)[1] - fm = np.sqrt(G.N)*np.kron(np.ones((nt, 1)), f)*np.kron(np.ones((1, nt)), G.U[:, k]) + fm = np.sqrt(G.N) * np.kron(np.ones((nt, 1)), f) * \ + np.kron(np.ones((1, nt)), G.U[:, k]) return fm @@ -253,6 +266,6 @@ def translate(G, f, i): fhat = gft(G, f) nt = np.shape(f)[1] - ft = np.sqrt(G.N)*igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) + ft = np.sqrt(G.N) * igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) return ft diff --git a/pygsp/utils.py b/pygsp/utils.py index 6b7f56d8..7ccba9b5 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- -r"""This module implements some utilitary functions used throughout the PyGSP box.""" +r""" +This module implements some utilitary functions used throughout the PyGSP box. +""" import numpy as np +import logging + from scipy import kron, ones from scipy import sparse -import logging def build_logger(name, **kwargs): @@ -215,4 +218,4 @@ def symmetrize(W, symmetrize_type='average'): W += mask.multiply(W.T) if sparse_flag else (mask * W.T) return (W + W.T) / 2. # Resolve ambiguous entries else: - return W + raise ValueError("Unknown symmetrize type.") From b7df920ae828a33a5940ee6ce2acb86937912841 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 23 Jun 2017 00:24:28 +0200 Subject: [PATCH 012/392] fix bug in Grid2d --- pygsp/graphs/grid2d.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 88a2087b..f78658fc 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -32,10 +32,11 @@ class Grid2d(Graph): def __init__(self, shape=(3,), **kwargs): # Parse shape try: - h, w = shape - except ValueError: h = shape[0] - w = h + try: + w = shape[1] + except ValueError: + w = h except TypeError: h = shape w = h From e46b78e75a351c23b6bd8251afee7aa0a57cfbe5 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 23 Jun 2017 12:46:49 +0200 Subject: [PATCH 013/392] add tests for the classes created --- pygsp/graphs/nngraphs/nngraph.py | 12 +++++------- pygsp/tests/test_graphs.py | 29 +++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index ff47ce01..ec978a3f 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -112,16 +112,14 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, spv = np.zeros((N * k)) if self.use_flann: - """ - I don't know if the parameters here are optimized for the - trade-off between accuracy and speed. I simply copied the - parameters used in the python example in FLANN's User Manual. - """ set_distance_type(dist_type, order=order) flann = 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="kmeans", branching=32, - iterations=7, checks=16) + algorithm='kdtree') else: kdt = spatial.KDTree(Xout) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 254263a5..67354f17 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -37,7 +37,12 @@ def test_default_graph(): def test_NNGraph(): Xin = np.arange(90).reshape(30, 3) - G = graphs.NNGraph(Xin) + dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + for dist_type in dist_types: + G1 = graphs.NNGraph(Xin, NNtype='knn', dist_type=dist_type) + G2 = graphs.NNGraph(Xin, use_flann=True, NNtype='knn', + dist_type=dist_type) + G3 = graphs.NNGraph(Xin, NNtype='radius', dist_type=dist_type) def test_Bunny(): G = graphs.Bunny() @@ -53,9 +58,6 @@ def test_TwoMoons(): G = graphs.TwoMoons() G2 = graphs.TwoMoons(moontype='synthetised') - def test_Grid2d(): - G = graphs.Grid2d() - def test_Torus(): G = graphs.Torus() @@ -103,6 +105,25 @@ def test_RandomRing(): def test_SwissRoll(): G = graphs.SwissRoll() + def test_Grid2d(): + G = graphs.Grid2d(shape=(3, 2)) + self.assertEqual([G.h, G.w], [3, 2]) + G = graphs.Grid2d(shape=(3,)) + self.assertEqual([G.h, G.w], [3, 3]) + G = graphs.Grid2d(shape=3) + self.assertEqual([G.h, G.w], [3, 3]) + + def test_ImgPatches(): + from skimage import data, img_as_float + img = img_as_float(data.camera()[::16, ::16]) + G = graphs.ImgPatches(img=img, patch_shape=(3, 3)) + + def test_Grid2dImgPatches(): + from skimage import data, img_as_float + img = img_as_float(data.camera()[::16, ::16]) + G = graphs.Grid2dImgPatches(img=img, patch_shape=(3, 3)) + + suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) From c9d03798519a8b3c5387f851675d6a13d6df79fe Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Thu, 29 Jun 2017 12:01:20 +0200 Subject: [PATCH 014/392] update README --- README.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 9d8205b4..b4a61d81 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,10 @@ A whole list of preconstructed graphs can be used as well as core functions to c - Cube - Sphere - TwoMoons + - ImgPatches + - Grid2dImgPatches - Airfoil + - BarabasiAlbert - Comet - Community - DavidSensorNet @@ -39,7 +42,7 @@ A whole list of preconstructed graphs can be used as well as core functions to c - Ring - Sensor - StochasticBlockModel - - Swiss roll + - SwissRoll - Torus On these graphs, filters can be applied to do signal processing. To this end, there is also a list of predefined filters on this toolbox:: @@ -79,7 +82,7 @@ For a classic UNIX system, you will need python-dev(el) (or equivalent) installe $ sudo apt-get install python-dev liblapack-dev libatlas-dev gcc gfortran Then, try again to install the pygsp:: - + $ pip install pygsp Plotting @@ -109,7 +112,8 @@ Authors * Basile Châtillon , * Alexandre Lafaye , * Lionel Martin , -* Nicolas Rod +* Nicolas Rod , +* Rodrigo Pena Acknowledgment -------------- From 2729440e559b62dd91a06d2b3e43c4c2b84530de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 11 Jul 2017 00:29:22 +0200 Subject: [PATCH 015/392] demo: plot 2nd and 3rd eigenvectors instead of 3rd and 4th Fixes issue #5 --- doc/tutorials/demo.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/tutorials/demo.rst b/doc/tutorials/demo.rst index 52d9d4d1..826d0a0f 100644 --- a/doc/tutorials/demo.rst +++ b/doc/tutorials/demo.rst @@ -35,8 +35,8 @@ Looks good isn't it? Now we can start to analyse the graph. The next step to com You can now access the eigenvalues of the fourier basis with G.e and the eigenvectors G.U, they look like sinuses on the graph. Let's plot the second and third eigenvector, as the one is only constant. ->>> pygsp.plotting.plt_plot_signal(G, G.U[:, 2], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_second_eigenvector') ->>> pygsp.plotting.plt_plot_signal(G, G.U[:, 3], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_third_eigenvector') +>>> pygsp.plotting.plt_plot_signal(G, G.U[:, 1], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_second_eigenvector') +>>> pygsp.plotting.plt_plot_signal(G, G.U[:, 2], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_third_eigenvector') .. figure:: img/logo_second_eigenvector.* From 8c9232cbe392f4d2c8a31302d16f2eafada337ae Mon Sep 17 00:00:00 2001 From: Lionel Martin Date: Thu, 13 Jul 2017 09:40:54 +0200 Subject: [PATCH 016/392] Set requirements issues and flann problems with python3 --- pygsp/graphs/nngraphs/nngraph.py | 13 +++++++++---- setup.py | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index ec978a3f..a5f19637 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -4,9 +4,14 @@ from .. import Graph from ...utils import symmetrize -from pyflann import * from scipy import sparse, spatial +try: + import pyflann as fl +except Exception as e: + print('ERROR : Could not import pyflann. Try to install it for faster kNN computations.') + pfl_import = False + class NNGraph(Graph): r""" @@ -111,9 +116,9 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, spj = np.zeros((N * k)) spv = np.zeros((N * k)) - if self.use_flann: - set_distance_type(dist_type, order=order) - flann = FLANN() + if self.use_flann and pfl_import: + fl.set_distance_type(dist_type, order=order) + flann = fl.FLANN() # Default FLANN parameters (I tried changing the algorithm and # testing performance on huge matrices, but the default one diff --git a/setup.py b/setup.py index d091ddd2..47483772 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ install_requires=[ 'numpy', 'scipy', + 'scikit-image', 'pyopengl', 'pyqtgraph<=0.10.1', 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', From 3aa2247a67a3f3cbc811269b1435fdef193c6a59 Mon Sep 17 00:00:00 2001 From: Lionel Martin Date: Thu, 13 Jul 2017 10:27:31 +0200 Subject: [PATCH 017/392] Improved pyflann requirements --- pygsp/graphs/nngraphs/nngraph.py | 1 + requirements.txt | 2 +- setup.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index a5f19637..68615948 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -8,6 +8,7 @@ try: import pyflann as fl + pfl_import = True except Exception as e: print('ERROR : Could not import pyflann. Try to install it for faster kNN computations.') pfl_import = False diff --git a/requirements.txt b/requirements.txt index e57f3642..16a4c0af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,4 @@ git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev # Required for efficiently dealing with images scikit-image -pyflann \ No newline at end of file +pyflann3 diff --git a/setup.py b/setup.py index 47483772..5175f700 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,8 @@ 'pyopengl', 'pyqtgraph<=0.10.1', 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', - 'PyQt5' if sys.version_info.major == 3 and sys.version_info.minor == 5 else 'PySide'], + 'PyQt5' if sys.version_info.major == 3 and sys.version_info.minor == 5 else 'PySide', + 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], license="BSD", keywords='graph signal processing toolbox filters pointclouds', platforms='any', From 325f2b13e2b9a6ff0ae64946784797d98d614004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 14:08:02 +0200 Subject: [PATCH 018/392] Fix #7 --- pygsp/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 7ccba9b5..996faee8 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -127,7 +127,7 @@ def distanz(x, y=None): # Size verification if rx != ry: - raise("The sizes of x and y do not fit") + raise ValueError("The sizes of x and y do not fit") xx = (x * x).sum(axis=0) yy = (y * y).sum(axis=0) From 1980215e67736d5a1e3af4f5269ad0b246ebb601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 14:24:20 +0200 Subject: [PATCH 019/392] Fix #6 --- pygsp/filters/filter.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index cd6950eb..28e845a4 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -154,7 +154,6 @@ def evaluate(self, x, *args, **kwargs): """ i = kwargs.pop('i', 0) - fd = np.zeros(x.size) fd = self.g[i](x) return fd @@ -287,10 +286,6 @@ def filterbank_bounds(self, N=999, bounds=None): rng = np.linspace(xmin, xmax, N) else: - if not hasattr(self.G, 'lmax'): - self.logger.info('FILTERBANK_BOUNDS: Has to estimate lmax.') - self.G.estimate_lmax() - if not hasattr(self.G, 'e'): self.logger.info( 'FILTERBANK_BOUNDS: Has to compute Fourier basis.') From e739537b09ee41be8fb570949776782a024f56b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 14:30:41 +0200 Subject: [PATCH 020/392] Fix #8 --- pygsp/filters/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 28e845a4..325fb29a 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -232,7 +232,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): tmpN = np.arange(N, dtype=int) for i in range(Nf): - s = s + operator.cheby_op(self.G, + s = s + fast_filtering.cheby_op(self.G, cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': From 92792f1918d4293cce0a2040e77b621c31dad1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 20:07:34 +0200 Subject: [PATCH 021/392] remove redundant and outdated information --- AUTHORS.rst | 17 ----------------- TROUBLESHOOTING.rst | 22 ---------------------- 2 files changed, 39 deletions(-) delete mode 100644 AUTHORS.rst delete mode 100644 TROUBLESHOOTING.rst diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index dc91288e..00000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,17 +0,0 @@ -======= -Credits -======= - -Development Lead ----------------- - -* LTS2 Graph Task Force , -* Basile Châtillon , -* Alexandre Lafaye , -* Lionel Martin , -* Nicolas Rod - -Contributors ------------- - -None yet. Why not be the first? diff --git a/TROUBLESHOOTING.rst b/TROUBLESHOOTING.rst deleted file mode 100644 index 0e48b2b9..00000000 --- a/TROUBLESHOOTING.rst +++ /dev/null @@ -1,22 +0,0 @@ -Troubleshooting -=============== - - -Installation ------------- - -If you have trouble installing the package under an UNIX sytem, here are some hints. - - -Ubuntu / Debian -^^^^^^^^^^^^^^^ - -You will need the python-dev, liblapack-dev, libatlas-dev and gcc with gfortran extension as system packages in order to compile numpy and scipy. - -Some versions of matplotlib have trouble finding sources, you'll maybe have to do some manual linking to install it. - - -General help -^^^^^^^^^^^^ - -You might find more help on the :ref:`About page `. From cdb26eda3844c5db05422ca7f4e539d8f7de12a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 20:57:56 +0200 Subject: [PATCH 022/392] typo --- 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 c59a1461..a06d9ae3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -155,7 +155,7 @@ def update_graph_attr(self, *args, **kwargs): >>> newW[1] = 1 >>> G.update_graph_attr('N', 'd', W=newW) - Updates all attributes of G excepted 'N' and 'd' + Updates all attributes of G except 'N' and 'd' """ graph_attr = {} From ae78da565a9064689183ad6b1838acbdb79cdcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 20:58:57 +0200 Subject: [PATCH 023/392] PyQt5 is required for Python newer than 3.5 too --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5175f700..2280c468 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ 'pyopengl', 'pyqtgraph<=0.10.1', 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', - 'PyQt5' if sys.version_info.major == 3 and sys.version_info.minor == 5 else 'PySide', + 'PyQt5' if sys.version_info >= (3, 5) else 'PySide', 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], license="BSD", keywords='graph signal processing toolbox filters pointclouds', From 2d9415e953c71ea965fbbda9bb24b7bb942a5817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 9 Aug 2017 21:00:23 +0200 Subject: [PATCH 024/392] update readme --- README.rst | 55 +++++++++++++++--------------------------------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/README.rst b/README.rst index b4a61d81..e2b477e0 100644 --- a/README.rst +++ b/README.rst @@ -1,22 +1,22 @@ .. _about: -===== -About -===== - -PyGSP is a Graph Signal Processing Toolbox implemented in Python. It is a port of the Matlab GSP toolbox. +======================================== +PyGSP: Graph Signal Processing in Python +======================================== .. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg :target: https://travis-ci.org/epfl-lts2/pygsp -* Development : https://github.com/epfl-lts2/pygsp -* GSP matlab toolbox : https://github.com/epfl-lts2/gspbox +* Documentation: https://lts2.epfl.ch/pygsp/ +* Development: https://github.com/epfl-lts2/pygsp +* Matlab counterpart: https://github.com/epfl-lts2/gspbox Features -------- -This toolbox facilitate graph constructions and give tools to perform signal processing on them. -A whole list of preconstructed graphs can be used as well as core functions to create any other graph among which:: +This package facilitates graph constructions and give tools to perform signal processing on them. + +A whole list of pre-constructed graphs can be used as well as core functions to create any other graph among which:: - Neighest Neighbor Graphs - Bunny @@ -64,44 +64,18 @@ On these graphs, filters can be applied to do signal processing. To this end, th Installation ------------ -Ubuntu -^^^^^^ -The PyGSP module is available on PyPI, the Python Package Index. -If you don't have pip, install it.:: - - $ sudo apt-get install python-pip - -Ideally, you should be able to install the PyGSP on your computer by simply entering the following command:: - - $ pip install pygsp - -This installation requires numpy and scipy. If you don't have them installed already, pip installing pygsp will try to install them for you. Note that these two mathematical libraries requires additional system packages. - -For a classic UNIX system, you will need python-dev(el) (or equivalent) installed as a system package as well as the fortran extension for your favorite compiler (gfortran for gcc). You will also need the blas/lapack implementation for your system. If you can't install numpy or scipy, try installing the following and then install numpy and scipy:: - - $ sudo apt-get install python-dev liblapack-dev libatlas-dev gcc gfortran - -Then, try again to install the pygsp:: +The PyGSP is available on PyPI:: $ pip install pygsp -Plotting -^^^^^^^^ -If you want to use the plotting functionalities of the PyGSP, you have to install matplotlib or pygtgraph. For matplotlib, just do:: - - $ sudo apt-get python-matplotlib - +It can be installed in development mode with:: -Another way is to manually download from PyPI, unpack the package and install with:: - - $ python setup.py install - -Instructions and requirements to install pyqtgraph can be found at http://www.pyqtgraph.org/. - -If you plan to use Python 3.5, you will need to install manually PyQt5 because there is no source on PyPI for it and PySide is not ported yet. + $ git clone git@github.com:epfl-lts2/pygsp.git + $ pip install -e pygsp Testing ^^^^^^^ + Execute the project test suite once to make sure you have a working install:: $ python setup.py test @@ -114,6 +88,7 @@ Authors * Lionel Martin , * Nicolas Rod , * Rodrigo Pena +* Michaël Defferrard Acknowledgment -------------- From 02e55ea2ba753a17cfda6e9a8c232277ab825e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 12:47:29 +0200 Subject: [PATCH 025/392] correct reference to filters doc --- pygsp/filters/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 4de750b8..a089ec83 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -6,7 +6,7 @@ A filter is associated to a graph and is defined with one or several function(s). We define by Filterbank a list of filters applied to a single graph. Tools for the analysis, the synthesis and the evaluation are provided to work with the filters on the graphs. -For specific information, :ref:`see details here`. +For specific information, :ref:`see details here`. """ import importlib From 91bed65ee17dae36ae21f78d9434ffc2c91c6040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 12:55:11 +0200 Subject: [PATCH 026/392] show doc for Gabor filters --- doc/reference/filters.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index c3cc64be..467124f0 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -29,6 +29,14 @@ Expwin :show-inheritance: :members: +Gabor +----- + +.. autoclass:: pygsp.filters.Gabor + :undoc-members: + :show-inheritance: + :members: + HalfCosine ---------- From 04ae8696540f40374411ae81cc48f78a96b22e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 12:55:52 +0200 Subject: [PATCH 027/392] doc: order filters by name --- doc/reference/filters.rst | 42 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index 467124f0..d71ab286 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -45,42 +45,42 @@ HalfCosine :show-inheritance: :members: -Itersine --------- +Heat +---- -.. autoclass:: pygsp.filters.Itersine +.. autoclass:: pygsp.filters.Heat :undoc-members: :show-inheritance: :members: -MexicanHat ----------- +Held +---- -.. autoclass:: pygsp.filters.MexicanHat +.. autoclass:: pygsp.filters.Held :undoc-members: :show-inheritance: :members: -Meyer ------ +Itersine +-------- -.. autoclass:: pygsp.filters.Meyer +.. autoclass:: pygsp.filters.Itersine :undoc-members: :show-inheritance: :members: -SimpleTf --------- +MexicanHat +---------- -.. autoclass:: pygsp.filters.SimpleTf +.. autoclass:: pygsp.filters.MexicanHat :undoc-members: :show-inheritance: :members: -WarpedTranslates ----------------- +Meyer +----- -.. autoclass:: pygsp.filters.WarpedTranslates +.. autoclass:: pygsp.filters.Meyer :undoc-members: :show-inheritance: :members: @@ -109,18 +109,18 @@ Simoncelli :show-inheritance: :members: -Held ----- +SimpleTf +-------- -.. autoclass:: pygsp.filters.Held +.. autoclass:: pygsp.filters.SimpleTf :undoc-members: :show-inheritance: :members: -Heat ----- +WarpedTranslates +---------------- -.. autoclass:: pygsp.filters.Heat +.. autoclass:: pygsp.filters.WarpedTranslates :undoc-members: :show-inheritance: :members: From 2149aa756551c463aeae7217bb750e2be1f9be42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 13:00:17 +0200 Subject: [PATCH 028/392] fix gabor filter (#9) --- pygsp/filters/gabor.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index e74f7e95..8ea37672 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -29,10 +29,10 @@ class Gabor(Filter): Examples -------- - >>> from pygsp import grpahs, filters + >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> k = lambda x: x/(1.-x) - >>> g = filters.Gabor(G, k); + >>> F = filters.Gabor(G, k); Author: Nathanael Perraudin Date : 13 June 2014 @@ -47,8 +47,6 @@ def __init__(self, G, k, **kwargs): Nf = np.shape(G.e)[0] - g = [] + self.g = [] for i in range(Nf): - g.append(lambda x, ii=i: k(x - G.e[ii])) - - return g + self.g.append(lambda x, ii=i: k(x - G.e[ii])) From 40e21f9cd7d722aed78dd7644c8520459694f771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 15:04:04 +0200 Subject: [PATCH 029/392] readme: add badges --- README.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.rst b/README.rst index e2b477e0..c9156054 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,18 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg :target: https://travis-ci.org/epfl-lts2/pygsp +.. image:: https://img.shields.io/pypi/v/pygsp.svg + :target: https://pypi.python.org/pypi/pygsp + +.. image:: https://img.shields.io/pypi/l/pygsp.svg + :target: https://pypi.python.org/pypi/pygsp + +.. image:: https://img.shields.io/pypi/pyversions/pygsp.svg + :target: https://pypi.python.org/pypi/pygsp + +.. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social + :target: https://github.com/epfl-lts2/pygsp + * Documentation: https://lts2.epfl.ch/pygsp/ * Development: https://github.com/epfl-lts2/pygsp * Matlab counterpart: https://github.com/epfl-lts2/gspbox From 005a1a0329d7581813322af02f51a5f0273a83b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 16:03:22 +0200 Subject: [PATCH 030/392] support python 2.7, 3.4, 3.5, 3.6 --- .travis.yml | 1 + setup.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 977ddab1..f5ab9628 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ python: - "2.7" - "3.4" - "3.5" + - "3.6" addons: apt: diff --git a/setup.py b/setup.py index 2280c468..94735d07 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ 'scikit-image', 'pyopengl', 'pyqtgraph<=0.10.1', - 'matplotlib==1.4.3' if sys.version_info.major == 3 and sys.version_info.minor < 4 else 'matplotlib', + 'matplotlib', 'PyQt5' if sys.version_info >= (3, 5) else 'PySide', 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], license="BSD", @@ -52,5 +52,6 @@ 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], ) From 0a439b8819472a7c2b191a7c497368e164d31e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 17:47:01 +0200 Subject: [PATCH 031/392] travis: use trusty instead of precise reason: pyqt5 needs glibc 2.17 (and trusty will become default soon anyway) --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index f5ab9628..92de2ae3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python +dist: trusty # need glibc >= 2.17 for pyqt5 sudo: false python: From 0d66b52dba204d45c3b5b97720b3cc2f989b77a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 17:49:50 +0200 Subject: [PATCH 032/392] travis: remove unecessary packages (were needed before wheels) --- .travis.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.travis.yml b/.travis.yml index 92de2ae3..a02b55d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,15 +14,6 @@ python: addons: apt: packages: - - python-dev - - pkg-config - - libfreetype6-dev - - libpng-dev - - liblapack-dev - - libatlas-dev - - gfortran - - libatlas-base-dev - - libx11-xcb1 - xvfb before_install: From 3a6aaa3a2cf77e3514fcc32d6b021b27ef8218cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 17:50:27 +0200 Subject: [PATCH 033/392] travis: fix spacing --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index a02b55d2..4c5918ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,14 +14,14 @@ python: addons: apt: packages: - - xvfb + - xvfb before_install: - - pip install -U pip - - pip install -U -r requirements.txt + - pip install -U pip + - pip install -U -r requirements.txt install: - - python setup.py install + - python setup.py install script: - - xvfb-run python setup.py test + - xvfb-run python setup.py test From aacc478e8e3bc526ec406197acb9b10b0677029e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 17:51:34 +0200 Subject: [PATCH 034/392] travis: enable cache to speed test time and avoid timeouts --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4c5918ab..f9f11df6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ # Configuration file for automatic testing at travis-ci.org -language: python - dist: trusty # need glibc >= 2.17 for pyqt5 sudo: false +language: python +cache: pip # cache wheels for faster tests (PySide build can timeout) python: - "2.7" - "3.4" From 61bdf41209a8d52908ea8502697f924432b1e0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 17:53:04 +0200 Subject: [PATCH 035/392] travis: build cache while avoiding timeout --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f9f11df6..31224d5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,4 +24,5 @@ install: - python setup.py install script: - - xvfb-run python setup.py test + - echo hello +# - xvfb-run python setup.py test From 0f2e6bb75243e4ff5c7102ea1bf0a0c473c7b61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 18:58:33 +0200 Subject: [PATCH 036/392] travis: should not timeout with cache --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 31224d5e..f9f11df6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,5 +24,4 @@ install: - python setup.py install script: - - echo hello -# - xvfb-run python setup.py test + - xvfb-run python setup.py test From fc7bcefc694824bb49276693e91b1dceba6e0383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 10 Aug 2017 19:56:21 +0200 Subject: [PATCH 037/392] only one requirements file, used for devel anyway --- dev_requirements.txt | 16 ---------------- requirements.txt | 28 +++++++++++++++++++--------- 2 files changed, 19 insertions(+), 25 deletions(-) delete mode 100644 dev_requirements.txt diff --git a/dev_requirements.txt b/dev_requirements.txt deleted file mode 100644 index c7a23138..00000000 --- a/dev_requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -# Required to build documentation. -sphinx -numpydoc -sphinxcontrib-bibtex -sphinx-rtd-theme - -# Required for packaging. -wheel - -# Required for code style checking. -flake8 - -# Required for extended testing. -tox -virtualenv - diff --git a/requirements.txt b/requirements.txt index 16a4c0af..31f31184 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,19 +2,29 @@ numpy scipy -# Required for plotting -matplotlib==1.4.3 ; python_version < '3.4' -matplotlib ; python_version >= '3.4' - +# Required for plotting. +matplotlib PySide ; python_version < '3.5' PyQt5 ; python_version >= '3.5' - pyopengl git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev -# Additional development requirements -# -r dev_requirements.txt - -# Required for efficiently dealing with images +# Required to efficiently deal with images. scikit-image pyflann3 + +# Required to build documentation. +sphinx +numpydoc +sphinxcontrib-bibtex +sphinx-rtd-theme + +# Required for packaging. +wheel + +# Required for code style checking. +flake8 + +# Required for extended testing. +tox +virtualenv From aaf169543fd98f81dbe93d83ecce0fb650f14972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 09:38:41 +0200 Subject: [PATCH 038/392] tox: testing multiple Pythons locally is painful Testing is taken care of by Travis. --- Makefile | 4 ---- requirements.txt | 4 ---- tox.ini | 8 -------- 3 files changed, 16 deletions(-) delete mode 100644 tox.ini diff --git a/Makefile b/Makefile index 63fc2517..ddbc9f8d 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,6 @@ help: @echo "clean-pyc - remove Python file artifacts" @echo "flake8 - check style with flake8" @echo "test - run tests quickly with the default Python" - @echo "test-all - run tests on every Python version with tox" @echo "coverage - check code coverage quickly with the default Python" @echo "doc - generate Sphinx HTML documentation, including API doc" @echo "release - package and upload a release" @@ -30,9 +29,6 @@ flake8: test: python setup.py test -test-all: - tox - coverage: coverage run --source pyGSP setup.py test coverage report -m diff --git a/requirements.txt b/requirements.txt index 31f31184..f0233e80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,3 @@ wheel # Required for code style checking. flake8 - -# Required for extended testing. -tox -virtualenv diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 922e5ed4..00000000 --- a/tox.ini +++ /dev/null @@ -1,8 +0,0 @@ -[tox] -envlist = py27, py34, py35 - -[testenv] -commands = python setup.py test -# No need to build and install for each environment numpy and matplotlib with -# all the dependencies needed to build them. -sitepackages = True From d1fba2007fb835eed426fb0a76f786691e3fecb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 10:26:51 +0200 Subject: [PATCH 039/392] pyqtgraph 0.10.0 is now on pypi --- requirements.txt | 2 +- setup.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index f0233e80..da7ee26a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,10 @@ scipy # Required for plotting. matplotlib +pyqtgraph PySide ; python_version < '3.5' PyQt5 ; python_version >= '3.5' pyopengl -git+https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-dev # Required to efficiently deal with images. scikit-image diff --git a/setup.py b/setup.py index 94735d07..60a26e3d 100644 --- a/setup.py +++ b/setup.py @@ -25,16 +25,14 @@ 'pygsp.pointclouds', 'pygsp.tests'], package_data={'pygsp.pointclouds': ['misc/*.mat']}, test_suite='pygsp.tests.test_all.suite', - dependency_links=[ - 'https://github.com/pyqtgraph/pyqtgraph@develop#egg=pyqtgraph-0.10.1fork'], install_requires=[ 'numpy', 'scipy', - 'scikit-image', - 'pyopengl', - 'pyqtgraph<=0.10.1', 'matplotlib', + 'pyqtgraph', 'PyQt5' if sys.version_info >= (3, 5) else 'PySide', + 'pyopengl', + 'scikit-image', 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], license="BSD", keywords='graph signal processing toolbox filters pointclouds', From cf242415ba48a6d6f3eaef408a23a54425f4e0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 10:57:38 +0200 Subject: [PATCH 040/392] lint: check doctests --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index ddbc9f8d..0f4d1fab 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ help: @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" - @echo "flake8 - check style with flake8" + @echo "lint - check style" @echo "test - run tests quickly with the default Python" @echo "coverage - check code coverage quickly with the default Python" @echo "doc - generate Sphinx HTML documentation, including API doc" @@ -23,8 +23,8 @@ clean-pyc: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + -flake8: - python setup.py flake8 +lint: + flake8 --doctests test: python setup.py test From 7ee9efcfc44552b9d68e70852ce745e4c612af51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 11:21:24 +0200 Subject: [PATCH 041/392] doc: simplify makefiles --- Makefile | 6 +- doc/Makefile | 177 ------------------------------------- doc/make.bat | 242 --------------------------------------------------- 3 files changed, 4 insertions(+), 421 deletions(-) delete mode 100644 doc/Makefile delete mode 100644 doc/make.bat diff --git a/Makefile b/Makefile index 0f4d1fab..a258d979 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,8 @@ help: clean: clean-build clean-pyc rm -fr htmlcov/ + # Documentation. + rm -rf doc/_build clean-build: rm -fr build/ @@ -36,8 +38,8 @@ coverage: open htmlcov/index.html doc: - $(MAKE) -C doc clean - $(MAKE) -C doc html + sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html + sphinx-build -b linkcheck -d doc/_build/doctrees doc doc/_build/linkcheck release: clean python setup.py register diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index 1005e265..00000000 --- a/doc/Makefile +++ /dev/null @@ -1,177 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." \ No newline at end of file diff --git a/doc/make.bat b/doc/make.bat deleted file mode 100644 index 2b447647..00000000 --- a/doc/make.bat +++ /dev/null @@ -1,242 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -:end \ No newline at end of file From f8d1271d29c8ddf7de7bf3f6bba992c5c8434f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 11:27:49 +0200 Subject: [PATCH 042/392] doc: fix links who were redirects --- CONTRIBUTING.rst | 2 +- README.rst | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b09fa09b..03dbd072 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -62,7 +62,7 @@ Ready to contribute? Here's how to set up `pygsp` for local development. $ git clone git@github.com:your_name_here/pygsp.git -3. Install your local copy into a virtualenv. Assuming you have `virtualenvwrapper `_ installed, this is how you set up your fork for local development:: +3. Install your local copy into a virtualenv. Assuming you have `virtualenvwrapper `_ installed, this is how you set up your fork for local development:: $ mkvirtualenv pygsp $ cd pygsp/ diff --git a/README.rst b/README.rst index c9156054..04017718 100644 --- a/README.rst +++ b/README.rst @@ -8,13 +8,13 @@ PyGSP: Graph Signal Processing in Python :target: https://travis-ci.org/epfl-lts2/pygsp .. image:: https://img.shields.io/pypi/v/pygsp.svg - :target: https://pypi.python.org/pypi/pygsp + :target: https://pypi.python.org/pypi/PyGSP .. image:: https://img.shields.io/pypi/l/pygsp.svg - :target: https://pypi.python.org/pypi/pygsp + :target: https://pypi.python.org/pypi/PyGSP .. image:: https://img.shields.io/pypi/pyversions/pygsp.svg - :target: https://pypi.python.org/pypi/pygsp + :target: https://pypi.python.org/pypi/PyGSP .. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp From 4474b9cb62ee58137d0c8666856cb688940f4d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:07:20 +0200 Subject: [PATCH 043/392] doc: fix warnings --- doc/reference/features.rst | 6 ++++++ doc/tutorials/index.rst | 1 + pygsp/graphs/graph.py | 8 +++----- pygsp/graphs/nngraphs/nngraph.py | 7 +++---- 4 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 doc/reference/features.rst diff --git a/doc/reference/features.rst b/doc/reference/features.rst new file mode 100644 index 00000000..51def591 --- /dev/null +++ b/doc/reference/features.rst @@ -0,0 +1,6 @@ +======== +Features +======== + +.. automodule:: pygsp.features + :members: diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index 550da358..d6581672 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -12,3 +12,4 @@ to solve some real problems. demo demo_wavelet demo_graph_tv + demo_pyramid diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index a06d9ae3..59ea55c3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -23,9 +23,8 @@ class Graph(object): A graph contains the following fields: - N : the number of nodes (also called vertices sometimes) in the - graph. - They represent the different points between which connections may - occur. + graph. They represent the different points between which + connections may occur. - Ne : the number of edges (also called links sometimes) in the graph. They represent the actual connections between the nodes. - W : the weight matrix contains the weights of the connections. @@ -49,8 +48,7 @@ class Graph(object): From a given matrix W, there exist several Laplacians that could be computed. - coords : the coordinates of the vertices in the 2D or 3D space for - plotting. - The default is None. + plotting. The default is None. - plotting : all the plotting parameters go here. They depend on the library used for plotting. diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 68615948..5195f38c 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -24,10 +24,9 @@ class NNGraph(Graph): 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' : k-Nearest Neighbors - 'radius' : epsilon-Nearest Neighbors - (default is 'knn') + 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) From 68c84b2364427f8af496946dc8f39132ac6ed9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:10:02 +0200 Subject: [PATCH 044/392] doc: show docstrings of decorated functions see https://docs.python.org/3.6/library/functools.html#functools.wraps --- pygsp/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 996faee8..52bb30e7 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -3,9 +3,10 @@ This module implements some utilitary functions used throughout the PyGSP box. """ -import numpy as np import logging +from functools import wraps +import numpy as np from scipy import kron, ones from scipy import sparse @@ -50,6 +51,7 @@ def inner(G, *args, **kwargs): def filterbank_handler(func): + @wraps(func) def inner(f, *args, **kwargs): if 'i' in kwargs: From 65429420b0a4c75b0fa57073a5644e09706bf0f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:31:45 +0200 Subject: [PATCH 045/392] doc: clean configuration --- doc/conf.py | 274 ++-------------------------------------------------- 1 file changed, 10 insertions(+), 264 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 8cd6d7d4..6f4988d7 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,285 +1,31 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# -# complexity documentation build configuration file, created by -# sphinx-quickstart on Tue Jul 9 22:26:36 2013. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys, os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# Get the project root dir, which is the parent dir of this -cwd = os.getcwd() -project_root = os.path.dirname(cwd) - -# Insert the project root dir as the first element in the PYTHONPATH. -# This lets us ensure that the source package is imported, and that its -# version is used. -sys.path.insert(0, project_root) import pygsp -# -- Library requirements ------------------------------------------------------ - -# RTD : This happens because our build system doesn't have the dependencies for -# building your project. This happens with things like libevent and mysql, and -# other python things that depend on C libraries. We can't support installing -# random C binaries on our system, so there is another way to fix these -# imports. - -# Other solution for RTD : give the virtual environment access to the -# global site-packages dir. - -#import mock - -#mock_modules = ['numpy'] -#for mod_name in mock_modules: -# sys.modules[mod_name] = mock.Mock() - - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', 'sphinx.ext.mathjax', 'numpydoc', + 'sphinx.ext.autosummary', 'sphinx.ext.mathjax', 'sphinx.ext.inheritance_diagram', 'sphinxcontrib.bibtex'] -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +extensions.append('numpydoc') +numpydoc_show_class_members = False -# The suffix of source filenames. +exclude_patterns = ['_build'] source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. master_doc = 'index' -# General information about the project. -project = pygsp.__name__ -copyright = '2014-2016, EPFL LTS2' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. +project = 'PyGSP' version = pygsp.__version__ -# The full version, including alpha/beta/rc tags. release = pygsp.__version__ +copyright = 'EPFL LTS2' -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. html_theme = 'sphinx_rtd_theme' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'pygspdoc' - - -# -- Options for LaTeX output -------------------------------------------------- - latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + 'papersize': 'a4paper', + 'pointsize': '10pt', } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'pygsp.tex', u'pygsp Documentation', - u'EPFL LTS2', 'manual'), + ('index', 'pygsp.tex', 'PyGSP documentation', + 'EPFL LTS2', 'manual'), ] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'pygsp', u'pygsp Documentation', - [u'EPFL LTS2'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'pygsp', u'pygsp Documentation', - u'EPFL LTS2', 'pygsp', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False - - -# -- numpydoc configuration ---------------------------------------------------- - -numpydoc_show_class_members = False From 3d36da7b6ba0b9d3db84ca5baee26c9cefae3d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:32:17 +0200 Subject: [PATCH 046/392] travis: build doc (and check style once fixed --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index f9f11df6..1f4aae3a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,4 +24,6 @@ install: - python setup.py install script: +# - make lint - xvfb-run python setup.py test + - make doc From e10f0be6918b5df1210a274313ff7f08918ff8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:38:56 +0200 Subject: [PATCH 047/392] wheel: specify universal in makefile --- Makefile | 4 ++-- setup.cfg | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) delete mode 100644 setup.cfg diff --git a/Makefile b/Makefile index a258d979..d19120f7 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,9 @@ doc: release: clean python setup.py register python setup.py sdist upload -# python setup.py bdist_wheel upload + python setup.py bdist_wheel --universal dist: clean python setup.py sdist -# python setup.py bdist_wheel + python setup.py bdist_wheel --universal ls -l dist diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 0a8df87a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[wheel] -universal = 1 \ No newline at end of file From 8cb6bc0728dd9de5d7a0a8a48f7a108efcf3a31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 12:39:46 +0200 Subject: [PATCH 048/392] distributed, but not in wheels anyway --- MANIFEST.in | 1 - 1 file changed, 1 deletion(-) delete mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index dff08a68..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1 +0,0 @@ -include *.txt *.rst From c6d9cee6a1fc7afcc266e1966bc180fba8cfa85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 13:20:31 +0200 Subject: [PATCH 049/392] documentation on read the docs --- README.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 04017718..95581673 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,9 @@ PyGSP: Graph Signal Processing in Python ======================================== +.. image:: https://readthedocs.org/projects/pygsp/badge/?version=latest + :target: https://pygsp.readthedocs.io/en/latest/ + .. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg :target: https://travis-ci.org/epfl-lts2/pygsp @@ -19,7 +22,7 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp -* Documentation: https://lts2.epfl.ch/pygsp/ +* Documentation: https://pygsp.readthedocs.io * Development: https://github.com/epfl-lts2/pygsp * Matlab counterpart: https://github.com/epfl-lts2/gspbox From ae5fa387bd13d45b1104c26568e8287a51481a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 13:20:51 +0200 Subject: [PATCH 050/392] no private repo anymore --- .gitlab-ci.yml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 5b3cafbd..00000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,4 +0,0 @@ -test: - script: - - pip install -r requirements.txt -U --user - - xvfb-run python setup.py test From 9d942c21fd2d4f52bdc76dc810473278477bc111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:10:34 +0200 Subject: [PATCH 051/392] tutorials: remove redundant script --- doc/tutorials/demo_pyramid.py | 32 -------------------------------- doc/tutorials/demo_pyramid.rst | 1 + 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 doc/tutorials/demo_pyramid.py diff --git a/doc/tutorials/demo_pyramid.py b/doc/tutorials/demo_pyramid.py deleted file mode 100644 index e340520a..00000000 --- a/doc/tutorials/demo_pyramid.py +++ /dev/null @@ -1,32 +0,0 @@ -import pygsp -import numpy as np -from pygsp import graphs, reduction, data_handling - -G = graphs.Sensor(512, distribute=True) - -g = [lambda x: 5./(5 + x)] - -Gs = reduction.kron_pyramid(G, 5, epsilon=0.1) -graphs.gutils.compute_fourier_basis(Gs) -graphs.gutils.estimate_lmax(Gs) - -f = np.ones((G.N)) -f[np.arange(G.N/2)] = -1 -f = f + 10*Gs[0].U[:, 7] - -f2 = np.ones((G.N, 2)) -f2[np.arange(G.N/2)] = -1 - -ca, pe = reduction.pyramid_analysis(Gs, f, filters=g, verbose=False) -ca2, pe2 = reduction.pyramid_analysis(Gs, f2, filters=g, verbose=False) - -coeff = data_handling.pyramid_cell2coeff(ca, pe) -coeff2 = data_handling.pyramid_cell2coeff(ca2, pe2) - -f_pred, _ = reduction.pyramid_synthesis(Gs, coeff, verbose=False) -f_pred2, _ = reduction.pyramid_synthesis(Gs, coeff2, verbose=False) - -err = np.linalg.norm(f_pred-f)/np.linalg.norm(f) -err2 = np.linalg.norm(f_pred2-f2)/np.linalg.norm(f2) -print('erreur de f (1d) : {}'.format(err)) -print('erreur de f2 (2d) : {}'.format(err2)) diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/demo_pyramid.rst index c2bf1c54..6af1afe0 100644 --- a/doc/tutorials/demo_pyramid.rst +++ b/doc/tutorials/demo_pyramid.rst @@ -46,3 +46,4 @@ Given the pyramid, the coarsest approximation and the prediction errors, we will Here are the final errors for each signal after reconstruction. >>> err = np.linalg.norm(f_pred-f)/np.linalg.norm(f) >>> err2 = np.linalg.norm(f_pred2-f2)/np.linalg.norm(f2) +>>> assert (err < 1e-10) & (err2 < 1e-10) From 967260d079f38d2e833184620fd92b69aa277258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:11:32 +0200 Subject: [PATCH 052/392] check test coverage --- .travis.yml | 9 +++++---- Makefile | 14 +++++--------- requirements.txt | 4 ++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1f4aae3a..22cf098b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,14 +16,15 @@ addons: packages: - xvfb -before_install: +install: - pip install -U pip - pip install -U -r requirements.txt - -install: - python setup.py install script: # - make lint - - xvfb-run python setup.py test + - xvfb-run make test - make doc + +after_success: + - coveralls diff --git a/Makefile b/Makefile index d19120f7..aeaaea49 100644 --- a/Makefile +++ b/Makefile @@ -4,14 +4,14 @@ help: @echo "clean-build - remove build artifacts" @echo "clean-pyc - remove Python file artifacts" @echo "lint - check style" - @echo "test - run tests quickly with the default Python" - @echo "coverage - check code coverage quickly with the default Python" + @echo "test - run tests and check coverage" @echo "doc - generate Sphinx HTML documentation, including API doc" @echo "release - package and upload a release" @echo "dist - package" clean: clean-build clean-pyc - rm -fr htmlcov/ + rm -rf .coverage + rm -rf htmlcov # Documentation. rm -rf doc/_build @@ -29,13 +29,9 @@ lint: flake8 --doctests test: - python setup.py test - -coverage: - coverage run --source pyGSP setup.py test - coverage report -m + coverage run --branch --source pygsp setup.py test + coverage report coverage html - open htmlcov/index.html doc: sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html diff --git a/requirements.txt b/requirements.txt index da7ee26a..baa50a70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,10 @@ numpydoc sphinxcontrib-bibtex sphinx-rtd-theme +# Required for testing. +coverage +coveralls + # Required for packaging. wheel From ce99c8416a24e9f0fd35c5914bf5d416ed2f3500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:14:58 +0200 Subject: [PATCH 053/392] use xvfb-run locally too to prevent spurious windows --- .travis.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 22cf098b..d15a1a81 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,7 +23,7 @@ install: script: # - make lint - - xvfb-run make test + - make test - make doc after_success: diff --git a/Makefile b/Makefile index aeaaea49..37dffbf5 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ lint: flake8 --doctests test: - coverage run --branch --source pygsp setup.py test + xvfb-run coverage run --branch --source pygsp setup.py test coverage report coverage html From ecba51b3b6f9286b5f55d1f953ea1e3c64bdefc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:17:37 +0200 Subject: [PATCH 054/392] upload to pypi with twine --- Makefile | 8 +++----- requirements.txt | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 37dffbf5..4563afe6 100644 --- a/Makefile +++ b/Makefile @@ -37,12 +37,10 @@ doc: sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html sphinx-build -b linkcheck -d doc/_build/doctrees doc doc/_build/linkcheck -release: clean - python setup.py register - python setup.py sdist upload - python setup.py bdist_wheel --universal - dist: clean python setup.py sdist python setup.py bdist_wheel --universal ls -l dist + +release: dist + twine upload dist/* diff --git a/requirements.txt b/requirements.txt index baa50a70..3e27026c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,7 @@ coveralls # Required for packaging. wheel +twine # Required for code style checking. flake8 From 5620aa42a67bb4e9d12021ff4ea42ef7733b4e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:26:55 +0200 Subject: [PATCH 055/392] simplify makefile --- Makefile | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index 4563afe6..e1ef1958 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,25 @@ -.PHONY: clean-pyc clean-build doc clean +.PHONY: help clean lint test doc dist release help: - @echo "clean-build - remove build artifacts" - @echo "clean-pyc - remove Python file artifacts" - @echo "lint - check style" - @echo "test - run tests and check coverage" - @echo "doc - generate Sphinx HTML documentation, including API doc" - @echo "release - package and upload a release" - @echo "dist - package" - -clean: clean-build clean-pyc - rm -rf .coverage - rm -rf htmlcov + @echo "clean remove non-source files" + @echo "lint check style" + @echo "test run tests and check coverage" + @echo "doc generate HTML documentation and check links" + @echo "dist package (source & wheel)" + @echo "release package and upload to PyPI" + +clean: + # Python files. + find . -name '__pycache__' -exec rm -rf {} + # Documentation. rm -rf doc/_build - -clean-build: - rm -fr build/ - rm -fr dist/ - rm -fr *.egg-info - -clean-pyc: - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + + # Coverage. + rm -rf .coverage + rm -rf htmlcov + # Package build. + rm -rf build + rm -rf dist + rm -rf *.egg-info lint: flake8 --doctests From a19adea4c6ef2a677fbf33fcf3ca83dcd222473c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:29:18 +0200 Subject: [PATCH 056/392] coveralls badge --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 95581673..a9c82afa 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,9 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg :target: https://travis-ci.org/epfl-lts2/pygsp +.. image:: https://img.shields.io/coveralls/epfl-lts2/pygsp.svg + :target: https://coveralls.io/github/epfl-lts2/pygsp + .. image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP From d33090e7eb008c1fb7cf7d62b10c8a457f2ec57b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 14:43:40 +0200 Subject: [PATCH 057/392] update setup.py --- setup.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/setup.py b/setup.py index 60a26e3d..ba96a542 100644 --- a/setup.py +++ b/setup.py @@ -1,25 +1,17 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import os import sys -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +from setuptools import setup -if sys.argv[-1] == 'publish': - os.system('python setup.py sdist upload') - sys.exit() setup( name='PyGSP', version='0.4.2', - description='The official Graph Signal Processing Toolbox', + description='Graph Signal Processing in Python', long_description=open('README.rst').read(), - author='Alexandre Lafaye, Basile Châtillon, Lionel Martin, Nicolas Rod (EPFL LTS2)', - author_email='alexandre.lafaye@epfl.ch, basile.chatillon@epfl.ch, lionel.martin@epfl.ch, nicolas.rod@epfl.ch', - url='https://github.com/epfl-lts2/', + author='EPFL LTS2', + url='https://github.com/epfl-lts2/pygsp', packages=['pygsp', 'pygsp.filters', 'pygsp.graphs', 'pygsp.graphs.nngraphs', 'pygsp.operators', 'pygsp.pointclouds', 'pygsp.tests'], @@ -33,14 +25,14 @@ 'PyQt5' if sys.version_info >= (3, 5) else 'PySide', 'pyopengl', 'scikit-image', - 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], + 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], license="BSD", - keywords='graph signal processing toolbox filters pointclouds', + keywords='graph signal processing', platforms='any', classifiers=[ 'Topic :: Scientific/Engineering :: Mathematics', 'Environment :: Console', - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Intended Audience :: Education', 'Intended Audience :: Science/Research', From 5fac5a6132acd703fb20de89acbf8f88583330c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:19:04 +0200 Subject: [PATCH 058/392] move history in doc --- HISTORY.rst | 80 ------------------------------------------------ doc/history.rst | 81 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 81 deletions(-) delete mode 100644 HISTORY.rst diff --git a/HISTORY.rst b/HISTORY.rst deleted file mode 100644 index 14535572..00000000 --- a/HISTORY.rst +++ /dev/null @@ -1,80 +0,0 @@ -.. :changelog: - -======= -History -======= - -0.4.2 (2017-04-27) ------------------- -Improve documentation. - -Various fixes. - - -0.4.1 (2016-09-06) ------------------- -Added routines to compute coordinates for the graphs. - -Added fast filtering of ideal band-pass. - -Implemented graph spectrogramms. - -Added the Barabási-Albert model for graphs. - -Renamed PointClouds features. - -Various fixes. - - -0.3.3 (2015-11-27) ------------------- - -Refactoring graphs using object programming and fail safe checks. - -Refactoring filters to use only the Graph object used at the construction of the filter for all operations. - -Refactoring Graph pyramid to match MATLAB implementation. - -Removal of default coordinates (all vertices on the origin) for graphs that do not possess spatial meaning. - -Correction of minor issues on Python3+ imports. - -Various fixes. - -Finalizing demos for the documentation. - - -0.2.1 (2015-10-14) ------------------- - -Fix bug on pip installation. - -Update full documentation. - - -0.2.0 (2015-10-05) ------------------- - -Adding functionalities to match the content of the Matlab GSP Box. - -First release of the PyGSP. - - -0.1.0 (2015-06-02) ------------------- - -Main features of the box are present most of the graphs and filters can be used. - -The utils and operators modules also have most of their features implemented. - - -0.0.2 (2015-04-19) ------------------- - -Beginning of user tests. - - -0.0.1 (2014-10-06) ------------------- - -Toolbox template release. diff --git a/doc/history.rst b/doc/history.rst index 25064996..14535572 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -1 +1,80 @@ -.. include:: ../HISTORY.rst +.. :changelog: + +======= +History +======= + +0.4.2 (2017-04-27) +------------------ +Improve documentation. + +Various fixes. + + +0.4.1 (2016-09-06) +------------------ +Added routines to compute coordinates for the graphs. + +Added fast filtering of ideal band-pass. + +Implemented graph spectrogramms. + +Added the Barabási-Albert model for graphs. + +Renamed PointClouds features. + +Various fixes. + + +0.3.3 (2015-11-27) +------------------ + +Refactoring graphs using object programming and fail safe checks. + +Refactoring filters to use only the Graph object used at the construction of the filter for all operations. + +Refactoring Graph pyramid to match MATLAB implementation. + +Removal of default coordinates (all vertices on the origin) for graphs that do not possess spatial meaning. + +Correction of minor issues on Python3+ imports. + +Various fixes. + +Finalizing demos for the documentation. + + +0.2.1 (2015-10-14) +------------------ + +Fix bug on pip installation. + +Update full documentation. + + +0.2.0 (2015-10-05) +------------------ + +Adding functionalities to match the content of the Matlab GSP Box. + +First release of the PyGSP. + + +0.1.0 (2015-06-02) +------------------ + +Main features of the box are present most of the graphs and filters can be used. + +The utils and operators modules also have most of their features implemented. + + +0.0.2 (2015-04-19) +------------------ + +Beginning of user tests. + + +0.0.1 (2014-10-06) +------------------ + +Toolbox template release. From 3b3af21e5ca5f62d93839d81a279336e39be203c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:21:45 +0200 Subject: [PATCH 059/392] doc: better history presentation --- doc/history.rst | 68 +++++++++++++++++-------------------------------- 1 file changed, 23 insertions(+), 45 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index 14535572..5cd499f7 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -1,80 +1,58 @@ -.. :changelog: - ======= History ======= 0.4.2 (2017-04-27) ------------------ -Improve documentation. - -Various fixes. +* Improve documentation. +* Various fixes. 0.4.1 (2016-09-06) ------------------ -Added routines to compute coordinates for the graphs. - -Added fast filtering of ideal band-pass. - -Implemented graph spectrogramms. - -Added the Barabási-Albert model for graphs. - -Renamed PointClouds features. - -Various fixes. +* Added routines to compute coordinates for the graphs. +* Added fast filtering of ideal band-pass. +* Implemented graph spectrogramms. +* Added the Barabási-Albert model for graphs. +* Renamed PointClouds features. +* Various fixes. 0.3.3 (2015-11-27) ------------------ -Refactoring graphs using object programming and fail safe checks. - -Refactoring filters to use only the Graph object used at the construction of the filter for all operations. - -Refactoring Graph pyramid to match MATLAB implementation. - -Removal of default coordinates (all vertices on the origin) for graphs that do not possess spatial meaning. - -Correction of minor issues on Python3+ imports. - -Various fixes. - -Finalizing demos for the documentation. - +* Refactoring graphs using object programming and fail safe checks. +* Refactoring filters to use only the Graph object used at the construction of the filter for all operations. +* Refactoring Graph pyramid to match MATLAB implementation. +* Removal of default coordinates (all vertices on the origin) for graphs that do not possess spatial meaning. +* Correction of minor issues on Python3+ imports. +* Various fixes. +* Finalizing demos for the documentation. 0.2.1 (2015-10-14) ------------------ -Fix bug on pip installation. - -Update full documentation. - +* Fix bug on pip installation. +* Update full documentation. 0.2.0 (2015-10-05) ------------------ -Adding functionalities to match the content of the Matlab GSP Box. - -First release of the PyGSP. - +* Adding functionalities to match the content of the Matlab GSP Box. +* First release of the PyGSP. 0.1.0 (2015-06-02) ------------------ -Main features of the box are present most of the graphs and filters can be used. - -The utils and operators modules also have most of their features implemented. - +* Main features of the box are present most of the graphs and filters can be used. +* The utils and operators modules also have most of their features implemented. 0.0.2 (2015-04-19) ------------------ -Beginning of user tests. - +* Beginning of user tests. 0.0.1 (2014-10-06) ------------------ -Toolbox template release. +* Toolbox template release. From d82649905baf86c2ed3164273f30da8ae1d70497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:22:45 +0200 Subject: [PATCH 060/392] doc: readme is the index --- doc/index.rst | 16 ++++------------ doc/readme.rst | 1 - 2 files changed, 4 insertions(+), 13 deletions(-) delete mode 100644 doc/readme.rst diff --git a/doc/index.rst b/doc/index.rst index b5e40d37..4fab6d08 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,19 +1,11 @@ -=================== -PyGSP documentation -=================== +.. include:: ../README.rst + .. toctree:: - :maxdepth: 2 + :hidden: - readme + Home tutorials/index reference/index contributing history references - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/doc/readme.rst b/doc/readme.rst deleted file mode 100644 index 72a33558..00000000 --- a/doc/readme.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../README.rst From e9668bbf42cf4fd2756f9b45d67020478fe1fe82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:35:46 +0200 Subject: [PATCH 061/392] doc: fix link --- doc/tutorials/demo.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/tutorials/demo.rst b/doc/tutorials/demo.rst index 826d0a0f..d66fcb69 100644 --- a/doc/tutorials/demo.rst +++ b/doc/tutorials/demo.rst @@ -15,7 +15,7 @@ The first step is to create a graph, there's a general class that can be used to >>> G = pygsp.graphs.Graph(W) -You have now a graph structure ready to be used everywhere in the box! If you want to know more about the Graph class and it's subclasses you can check the online doc at : https://lts2.epfl.ch/pygsp/ +You have now a graph structure ready to be used everywhere in the box! Check the :ref:`reference-guide` to know more about the Graph class and it's subclasses. You can also check the included methods for all graphs with the usual help function. For the next steps of the demo, we will be using the logo graph bundled with the toolbox : From 9a054b4633d40a8d6a99a39813a15a85f0f2e7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:40:06 +0200 Subject: [PATCH 062/392] update contribution guide --- CONTRIBUTING.rst | 133 +++++++++++------------------------------------ README.rst | 13 ++--- 2 files changed, 32 insertions(+), 114 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 03dbd072..190b2059 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,115 +1,40 @@ +.. _contributing: + ============ Contributing ============ -Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. - -You can contribute in many ways: - -Types of Contributions ----------------------- - -Report Bugs -~~~~~~~~~~~ - -Report bugs at https://github.com/epfl-lts2/pygsp/issues. - -If you are reporting a bug, please include: - -* Your operating system name and version. -* Any details about your local setup that might be helpful in troubleshooting. -* Detailed steps to reproduce the bug. - -Fix Bugs -~~~~~~~~ - -Look through the GitHub issues for bugs. Anything tagged with "bug" -is open to whoever wants to fix it. - -Implement Features -~~~~~~~~~~~~~~~~~~ - -Look through the GitHub issues for features. Anything tagged with "feature" -is open to whoever wants to implement it. - -Write Documentation -~~~~~~~~~~~~~~~~~~~ - -PyGSP could always use more documentation, whether as part of the -official PyGSP docs, in docstrings, or even on the web in blog posts, -articles, and such. - -Submit Feedback -~~~~~~~~~~~~~~~ - -The best way to send feedback is to file an issue at https://github.com/epfl-lts2/pygsp/issues. - -If you are proposing a feature: - -* Explain in detail how it would work. -* Keep the scope as narrow as possible, to make it easier to implement. -* Remember that this is a volunteer-driven project, and that contributions - are welcome :) - -Get Started! ------------- - -Ready to contribute? Here's how to set up `pygsp` for local development. - -1. Fork the `pygsp` repo on GitHub. -2. Clone your fork locally:: - - $ git clone git@github.com:your_name_here/pygsp.git - -3. Install your local copy into a virtualenv. Assuming you have `virtualenvwrapper `_ installed, this is how you set up your fork for local development:: - - $ mkvirtualenv pygsp - $ cd pygsp/ - $ python setup.py develop - -Note: alternatively, the third step could be replaced by:: - - $ pip install -e . - -4. Create a branch for local development:: - - $ git checkout -b name-of-your-bugfix-or-feature - - Now you can make your changes locally. - -5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: - - $ flake8 pygsp tests - $ python setup.py test - $ tox - - To get flake8 and tox, just pip install them into your virtualenv. - -6. Commit your changes and push your branch to GitHub:: - - $ git add * - $ git commit -m "Your detailed description of your changes." - $ git push --set-upstream origin name-of-your-branch +Contributions are welcome, and they are greatly appreciated! The development of +this package takes place on `GitHub `_. +Issues, bugs, and feature requests should be reported `there +`_. +Code and documentation can be improved by submitting a `pull request +`_. Please add documentation and +tests for any new code. -7. Submit a pull request through the GitHub website. +The package can be set up (ideally in a virtual environment) for local +development with the following:: -Pull Request Guidelines ------------------------ + $ git clone git@github.com:epfl-lts2/pygsp.git + $ pip install -U -r pygsp/requirements.txt + $ pip install -e pygsp -Before you submit a pull request, check that it meets these guidelines: +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 +example to demonstrate the use of the introduced functionality, please consider +adding a tutorial in ``doc/tutorials``. -1. The pull request should include tests. -2. If the pull request adds functionality, the docs should be updated. Put - your new functionality into a function with a docstring, and add the - feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.2, and 3.4, and for PyPy. Check - https://travis-ci.org/epfl-lts2/pygsp/pull_requests - and make sure that the tests pass for all supported Python versions. +Do not forget to update ``README.rst`` and ``doc/history.rst`` with e.g. new +features. The version number needs to be updated in ``setup.py`` and +``pyunlocbox/__init__.py``. -Tips ----- +After making any change, please check the style, run the tests, and build the +documentation with the following (enforced by Travis CI):: -To run a subset of tests:: + $ make lint + $ make test + $ make doc - $ python -m unittest tests.test_pygsp +Check the generated coverage report at ``htmlcov/index.html`` to make sure the +tests reasonably cover the changes you've introduced. diff --git a/README.rst b/README.rst index a9c82afa..2707a2b3 100644 --- a/README.rst +++ b/README.rst @@ -86,17 +86,10 @@ The PyGSP is available on PyPI:: $ pip install pygsp -It can be installed in development mode with:: - - $ git clone git@github.com:epfl-lts2/pygsp.git - $ pip install -e pygsp - -Testing -^^^^^^^ - -Execute the project test suite once to make sure you have a working install:: +Contributing +------------ - $ python setup.py test +See :ref:`contributing`. Authors ------- From 28a6b32ac76af32c823a16f4b39689e47dceaf53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:49:47 +0200 Subject: [PATCH 063/392] doc: repo organization in contributing --- CONTRIBUTING.rst | 37 +++++++++++++++++++++++++++++++++++ FILES.txt | 51 ------------------------------------------------ 2 files changed, 37 insertions(+), 51 deletions(-) delete mode 100644 FILES.txt diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 190b2059..cd4d94f5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -38,3 +38,40 @@ 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. + +Repository organization +----------------------- + +:: + + LICENSE.txt Project license + *.rst Important documentation + Makefile Targets for make + requirements.txt List of packages installed by pip (strong dep in setup.py) + setup.py Meta information about package (published on PyPI) + .gitignore Files ignored by the git revision control system + .travis.yml Defines testing on Travis continuous integration + + pygsp/ Contains the modules (the actual toolbox implementation) + __init.py__ Load modules at package import + *.py One file per module + + pygsp/tests/ Contains the test suites (will be distributed to end user) + __init.py__ Load modules at package import + test_*.py One test suite per module + test_docstrings.py Test the examples in the docstrings (reference doc) + test_tutorials.py Test the tutorials in doc/tutorials + test_all.py Launch all the tests (docstrings, tutorials, modules) + + doc/ Package documentation + conf.py Sphinx configuration + index.rst Documentation entry page + *.rst Include doc files from root directory + + doc/reference/ Reference documentation + index.rst Reference entry page + *.rst Only directives, the actual doc is alongside the code + + doc/tutorials/ + index.rst Tutorials entry page + *.rst One file per tutorial diff --git a/FILES.txt b/FILES.txt deleted file mode 100644 index 1e97ed15..00000000 --- a/FILES.txt +++ /dev/null @@ -1,51 +0,0 @@ -To be modified upon project creation ------------------------------------- - -pygsp Rename the directory with the package name -setup.py Insert project name, description, authors, version, dependencies -doc/conf.py Change package name and copyright -pygsp/__init__.py Change version and release date - -Implement code in pygsp/ -Implement tests in pygsp/tests/ -Implement documentation in doc/ -(note that reference documentation goes alongside the code) - - -File list ---------- - -*.rst Part of the documentation -LICENSE.txt Project license (3-clause BSD) -Makefile Targets for make : python setup.py test --> make test -MANIFEST.in List of files to be included -requirements.txt List of packages installed by pip (strong dep in setup.py) -setup.cfg Wheel built-package configuration -setup.py Meta information about package (published on PyPI) -tox.ini Defines local testing on multiple Python versions -.gitignore Files ignored by the git revision control system -.travis.yml Defines testing on Travis continuous integration - -pygsp/ Contains the modules (the actual toolbox implementation) - __init.py__ Load modules at package import - *.py One file per module - -pygsp/tests/ Contains the test suites (will be distributed to end user) - __init.py__ Load modules at package import - test_*.py One test suite per module - test_docstrings.py Test the examples in the docstrings (reference doc) - test_tutorials.py Test the tutorials in doc/tutorials - test_all.py Launch all the tests (docstrings, tutorials, modules) - -doc/ Package documentation - conf.py Sphinx configuration - index.rst Documentation entry page - *.rst Include doc files from root directory - -doc/reference/ Reference documentation - index.rst Reference entry page - *.rst Only directives, the actual doc is alongside the code - -doc/tutorials/ - index.rst Tutorials entry page - *.rst One file per tutorial From d2bedad6ea4e31545015838700322c611154956b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 17:58:11 +0200 Subject: [PATCH 064/392] update readme --- README.rst | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index 2707a2b3..030d41e3 100644 --- a/README.rst +++ b/README.rst @@ -25,9 +25,14 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp -* Documentation: https://pygsp.readthedocs.io -* Development: https://github.com/epfl-lts2/pygsp -* Matlab counterpart: https://github.com/epfl-lts2/gspbox +The PyGSP is a Python package to ease `Signal Processing on Graphs +`_ +(a `Matlab counterpart `_ +exists). It is a free software, distributed under the BSD license, and +available on `PyPI `_. The +documentation is available on `Read the Docs +`_ and development takes place on `GitHub +`_. Features -------- @@ -91,17 +96,10 @@ Contributing See :ref:`contributing`. -Authors -------- +Acknowledgments +--------------- -* Basile Châtillon , -* Alexandre Lafaye , -* Lionel Martin , -* Nicolas Rod , -* Rodrigo Pena -* Michaël Defferrard - -Acknowledgment --------------- - -This project has been partly funded by the Swiss National Science Foundation under grant 200021_154350 "Towards Signal Processing on Graphs". +The PyGSP was started in 2014 as an academic open-source project for +research purpose at the `EPFL LTS2 laboratory `_. +This project has been partly funded by the Swiss National Science Foundation +under grant 200021_154350 "Towards Signal Processing on Graphs". From 1bfd363affa1fae236c4b4fc11a3b6da3b8d88b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 11 Aug 2017 18:14:41 +0200 Subject: [PATCH 065/392] readme: github don't understand ref --- CONTRIBUTING.rst | 2 -- README.rst | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index cd4d94f5..78721dc6 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,5 +1,3 @@ -.. _contributing: - ============ Contributing ============ diff --git a/README.rst b/README.rst index 030d41e3..6a2ad054 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ The PyGSP is available on PyPI:: Contributing ------------ -See :ref:`contributing`. +See the guidelines for contributing in ``CONTRIBUTING.rst``. Acknowledgments --------------- From 87619611df21ae345a2762fcf712243ed18e985e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 12 Aug 2017 18:29:04 +0200 Subject: [PATCH 066/392] remove email --- pygsp/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index a8f53b45..4d1d37d4 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -41,5 +41,4 @@ assert utils __version__ = '0.4.2' -__email__ = 'LTS2Graph@groupes.epfl.ch' __release_date__ = '2017-04-27' From fa77e792c3b1e9044a322494dfa51d475c5e325e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 10:41:46 +0200 Subject: [PATCH 067/392] simple usage example goes in readme --- README.rst | 7 +++++++ doc/tutorials/index.rst | 1 - doc/tutorials/simple.rst | 10 ---------- 3 files changed, 7 insertions(+), 11 deletions(-) delete mode 100644 doc/tutorials/simple.rst diff --git a/README.rst b/README.rst index 6a2ad054..d58b63b6 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,13 @@ documentation is available on `Read the Docs `_ and development takes place on `GitHub `_. +This example demonstrates how to create a graph, a filter and analyse a signal on the graph. + +>>> import pygsp +>>> G = pygsp.graphs.Logo() +>>> f = pygsp.filters.Heat(G) +>>> Sl = f.analysis(G.L.todense(), method='cheby') + Features -------- diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index d6581672..5f6b7c81 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -8,7 +8,6 @@ to solve some real problems. .. toctree:: :maxdepth: 2 - simple demo demo_wavelet demo_graph_tv diff --git a/doc/tutorials/simple.rst b/doc/tutorials/simple.rst deleted file mode 100644 index 5563d8e8..00000000 --- a/doc/tutorials/simple.rst +++ /dev/null @@ -1,10 +0,0 @@ -============== -Simple problem -============== - -This example demonstrates how to create a graph, a filter and analyse a signal on the graph. - ->>> import pygsp ->>> G = pygsp.graphs.Logo() ->>> f = pygsp.filters.Heat(G) ->>> Sl = f.analysis(G.L.todense(), method='cheby') From cd1268b8d455ed384d2c577b1d73e56e65054fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 10:56:42 +0200 Subject: [PATCH 068/392] tutorials: better presentation --- doc/tutorials/demo.rst | 13 +++++-------- doc/tutorials/demo_graph_tv.rst | 13 ++++++------- doc/tutorials/demo_pyramid.rst | 8 ++++---- doc/tutorials/demo_wavelet.rst | 8 +++----- doc/tutorials/index.rst | 2 +- 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/doc/tutorials/demo.rst b/doc/tutorials/demo.rst index d66fcb69..65378739 100644 --- a/doc/tutorials/demo.rst +++ b/doc/tutorials/demo.rst @@ -1,6 +1,6 @@ -======== -GSP Demo -======== +========================= +Introduction to the PyGSP +========================= This tutorial shows basic operations of the toolbox. To start open a python shell (IPython is recommended here) and import the pygsp. You would probably also import numpy as you will need it to create matrices and arrays. @@ -14,7 +14,6 @@ The first step is to create a graph, there's a general class that can be used to >>> W = np.random.rand(400, 400) >>> G = pygsp.graphs.Graph(W) - You have now a graph structure ready to be used everywhere in the box! Check the :ref:`reference-guide` to know more about the Graph class and it's subclasses. You can also check the included methods for all graphs with the usual help function. @@ -70,8 +69,7 @@ You can also put multiple functions in a list to define a filterbank! Here's our low pass filter. - -To accompain our new filter, let's create a nice signal on the logo by setting each letter to a certain value and then adding some random noise. +To go with our new filter, let's create a nice signal on the logo by setting each letter to a certain value and then adding some random noise. >>> f = np.zeros((G.N,)) >>> f[G.info['idx_g']-1] = - 1 @@ -92,7 +90,6 @@ Finally here's the noisy signal and the denoised version right under. .. image:: img/noisy_logo.* .. image:: img/denoised_logo.* -So here are the basics for the PyGSP toolbox, if you want more informations you can check the doc in :ref:`the reference guide section `. - +So here are the basics for the PyGSP toolbox, please check the other tutorials or the `reference guide ` for more. Enjoy the toolbox! diff --git a/doc/tutorials/demo_graph_tv.rst b/doc/tutorials/demo_graph_tv.rst index 25496088..3d2deaa7 100644 --- a/doc/tutorials/demo_graph_tv.rst +++ b/doc/tutorials/demo_graph_tv.rst @@ -1,9 +1,9 @@ -************************************************************************ -GSP Graph TV Demo - Reconstruction of missing sample on a graph using TV -************************************************************************ +===================================================== +Reconstruction of missing samples on a graph using TV +===================================================== Description -########### +----------- Reconstruction of missing sample on a graph using TV @@ -39,7 +39,7 @@ This previous problem has an identical solution as: It is simply a projection on the B2-ball. Results and code -################ +---------------- >>> from pygsp import graphs, plotting >>> import numpy as np @@ -98,9 +98,8 @@ mask and addition of noise. More than half of the vertices are set to 0. This figure shows the reconstructed signal thanks to the algorithm. - Comparison with Tikhonov regularization -####################################### +--------------------------------------- We can also use the Tikhonov regularizer that will promote smoothness. In this case, we solve: diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/demo_pyramid.rst index 6af1afe0..49dcf22f 100644 --- a/doc/tutorials/demo_pyramid.rst +++ b/doc/tutorials/demo_pyramid.rst @@ -1,8 +1,8 @@ -================ -GSP Demo Pyramid -================ +============================================ +Graph multiresolution: reduction and pyramid +============================================ -In this demonstration file, we show how to reduce a graph using the GSPBox. Then we apply the pyramid to simple signal. +In this demonstration file, we show how to reduce a graph using the PyGSP. Then we apply the pyramid to simple signal. To start open a python shell (IPython is recommended here) and import the required packages. You would probably also import numpy as you will need it to create matrices and arrays. >>> import numpy as np diff --git a/doc/tutorials/demo_wavelet.rst b/doc/tutorials/demo_wavelet.rst index e5b44f16..e32cd316 100644 --- a/doc/tutorials/demo_wavelet.rst +++ b/doc/tutorials/demo_wavelet.rst @@ -1,8 +1,6 @@ -================ -GSP Wavelet Demo -================ - -* Introduction to spectral graph wavelet with the PyGSP +======================================= +Introduction to spectral graph wavelets +======================================= Description ----------- diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index 5f6b7c81..e3c02d8f 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -6,7 +6,7 @@ The following are some tutorials which show and explain how to use the toolbox to solve some real problems. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 demo demo_wavelet From 120cb697a5361ba23ac897d4f84ea3c7b55aba0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 22:41:29 +0200 Subject: [PATCH 069/392] tests: no support for Python 2.6 --- pygsp/tests/test_filters.py | 8 +------- pygsp/tests/test_graphs.py | 8 +------- pygsp/tests/test_plotting.py | 8 +------- pygsp/tests/test_utils.py | 8 +------- 4 files changed, 4 insertions(+), 28 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 1e32f462..ebb0d5db 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -3,16 +3,10 @@ """Test suite for the filters module of the pygsp package.""" -import sys +import unittest from pygsp import graphs, filters from numpy import zeros -# Use the unittest2 backport on Python 2.6 to profit from the new features. -if sys.version_info < (2, 7): - import unittest2 as unittest -else: - import unittest - class FunctionsTestCase(unittest.TestCase): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 67354f17..a437ef23 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -3,17 +3,11 @@ """Test suite for the graphs module of the pygsp package.""" -import sys +import unittest import numpy as np from scipy import sparse from pygsp import graphs -# Use the unittest2 backport on Python 2.6 to profit from the new features. -if sys.version_info < (2, 7): - import unittest2 as unittest -else: - import unittest - class FunctionsTestCase(unittest.TestCase): diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 33c53874..5d801fd7 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -3,16 +3,10 @@ """Test suite for the plotting module of the pygsp package.""" -import sys +import unittest import numpy as np from pygsp import graphs -# Use the unittest2 backport on Python 2.6 to profit from the new features. -if sys.version_info < (2, 7): - import unittest2 as unittest -else: - import unittest - class FunctionsTestCase(unittest.TestCase): diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 07c22f4f..d4220c10 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -5,18 +5,12 @@ Test suite for the utils module of the pygsp package. """ -import sys +import unittest import numpy as np import numpy.testing as nptest from scipy import sparse from pygsp import utils, graphs, operators -# Use the unittest2 backport on Python 2.6 to profit from the new features. -if sys.version_info < (2, 7): - import unittest2 as unittest -else: - import unittest - class FunctionsTestCase(unittest.TestCase): From 6154f2993c7d871416a5c9664f8212ad0bf56665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 22:50:11 +0200 Subject: [PATCH 070/392] update gitignore --- .gitignore | 28 +++------------------------- doc/tutorials/img/.gitignore | 2 ++ 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 795bd6da..bee062ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,5 @@ *.py[cod] -# C extensions -*.so - # Packages *.egg *.egg-info @@ -15,42 +12,23 @@ var sdist develop-eggs .installed.cfg -lib -lib64 # Installer logs pip-log.txt -# Unit test / coverage reports +# Coverage reports .coverage -.tox -nosetests.xml htmlcov -# Translations -*.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - # Complexity output/*.html output/*/index.html -# Sphinx +# Sphinx documentation doc/_build -doc/tutorials/img/*\.pdf -doc/tutorials/img/*\.png + # Vim swap files .*.swp -.*.swo -docs/_build - -.ropeproject - -.eggs/* # Mac OS garbage .DS_Store diff --git a/doc/tutorials/img/.gitignore b/doc/tutorials/img/.gitignore index e69de29b..66335542 100644 --- a/doc/tutorials/img/.gitignore +++ b/doc/tutorials/img/.gitignore @@ -0,0 +1,2 @@ +*.png +*.pdf From cc4b212c16bedcbbbc2b1461be7ede4a773d3608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 22:53:45 +0200 Subject: [PATCH 071/392] tests: tutorials and docstrings in main Only the docstrings of the 6 python files in the pygsp root were tested! --- pygsp/tests/__init__.py | 3 --- pygsp/tests/test_all.py | 29 +++++++++++++++++++++-------- pygsp/tests/test_docstrings.py | 29 ----------------------------- pygsp/tests/test_tutorials.py | 30 ------------------------------ 4 files changed, 21 insertions(+), 70 deletions(-) delete mode 100644 pygsp/tests/test_docstrings.py delete mode 100644 pygsp/tests/test_tutorials.py diff --git a/pygsp/tests/__init__.py b/pygsp/tests/__init__.py index f87163e5..78c23e66 100644 --- a/pygsp/tests/__init__.py +++ b/pygsp/tests/__init__.py @@ -3,13 +3,10 @@ # When importing the tests, you surely want these modules. from pygsp.tests import test_all -from pygsp.tests import test_docstrings, test_tutorials from pygsp.tests import test_graphs, test_filters, test_utils, test_plotting # Silence the code checker warning about unused symbols. assert test_all -assert test_docstrings -assert test_tutorials assert test_graphs assert test_filters assert test_utils diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index 179b9f3b..d13dae40 100644 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -3,28 +3,41 @@ """ Test suite for the pygsp package. + """ +import os import unittest -from pygsp.tests import test_docstrings, test_tutorials -from pygsp.tests import test_graphs, test_filters, test_utils, test_plotting +import doctest +from . import test_graphs, test_filters, test_utils, test_plotting + + +def gen_recursive_file(root, ext): + for root, dirnames, filenames in os.walk(root): + for name in filenames: + if name.lower().endswith(ext): + yield os.path.join(root, name) -suites = [] -suites.append(test_docstrings.suite) -suites.append(test_tutorials.suite) +def test_docstrings(root, ext): + files = list(gen_recursive_file(root, ext)) + return doctest.DocFileSuite(*files, module_relative=False) + + +suites = [] suites.append(test_graphs.suite) suites.append(test_utils.suite) suites.append(test_filters.suite) suites.append(test_plotting.suite) - +suites.append(test_docstrings('pygsp', '.py')) +suites.append(test_docstrings('.', '.rst')) suite = unittest.TestSuite(suites) -def run(): +def run(): # pragma: no cover unittest.TextTestRunner(verbosity=2).run(suite) -if __name__ == '__main__': +if __name__ == '__main__': # pragma: no cover run() diff --git a/pygsp/tests/test_docstrings.py b/pygsp/tests/test_docstrings.py deleted file mode 100644 index 8d9bb4a2..00000000 --- a/pygsp/tests/test_docstrings.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Test suite for the docstrings of the pygsp package.""" - -import doctest -import glob -import os -import unittest - - -files = [] - -# Test examples in docstrings. -base = os.path.join(os.path.dirname(__file__), os.path.pardir) -base = os.path.abspath(base) -files.extend(glob.glob(os.path.join(base, '*.py'))) - -assert files - -suite = doctest.DocFileSuite(*files, module_relative=False, encoding='utf-8') - - -def run(): - unittest.TextTestRunner(verbosity=2).run(suite) - - -if __name__ == '__main__': - run() diff --git a/pygsp/tests/test_tutorials.py b/pygsp/tests/test_tutorials.py deleted file mode 100644 index 076ff8c2..00000000 --- a/pygsp/tests/test_tutorials.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Test suite for the documentation of the pygsp package.""" - -import doctest -import glob -import os -import unittest - - -files = [] - -# Test examples in documentation. -base = os.path.join(os.path.dirname(__file__), os.path.pardir) -base = os.path.join(base, os.path.pardir, 'doc', 'tutorials') -base = os.path.abspath(base) -files.extend(glob.glob(os.path.join(base, '*.rst'))) - -assert files - -suite = doctest.DocFileSuite(*files, module_relative=False, encoding='utf-8') - - -def run(): - unittest.TextTestRunner(verbosity=2).run(suite) - - -if __name__ == '__main__': - run() From 82e2edc6f02930a0ec968d2c1dbe370dcab316ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 23:07:20 +0200 Subject: [PATCH 072/392] tests: only test_all.py is executable --- pygsp/tests/__init__.py | 13 ------------- pygsp/tests/test_all.py | 0 pygsp/tests/test_filters.py | 10 ---------- pygsp/tests/test_graphs.py | 9 --------- pygsp/tests/test_plotting.py | 10 +--------- pygsp/tests/test_utils.py | 10 +--------- 6 files changed, 2 insertions(+), 50 deletions(-) mode change 100644 => 100755 pygsp/tests/test_all.py diff --git a/pygsp/tests/__init__.py b/pygsp/tests/__init__.py index 78c23e66..e69de29b 100644 --- a/pygsp/tests/__init__.py +++ b/pygsp/tests/__init__.py @@ -1,13 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# When importing the tests, you surely want these modules. -from pygsp.tests import test_all -from pygsp.tests import test_graphs, test_filters, test_utils, test_plotting - -# Silence the code checker warning about unused symbols. -assert test_all -assert test_graphs -assert test_filters -assert test_utils -assert test_plotting diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py old mode 100644 new mode 100755 diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index ebb0d5db..35874c37 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -96,14 +96,4 @@ def test_analysis(G): # self.assertAlmostEqual(c_exact, c_lanczos) - suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) - - -def run(): - """Run tests.""" - unittest.TextTestRunner(verbosity=2).run(suite) - - -if __name__ == '__main__': - run() diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index a437ef23..3a536f50 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -119,12 +119,3 @@ def test_Grid2dImgPatches(): suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) - - -def run(): - """Run tests.""" - unittest.TextTestRunner(verbosity=2).run(suite) - - -if __name__ == '__main__': - run() diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 5d801fd7..a3e642d4 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -127,13 +127,5 @@ def test_SwissRoll(): G = graphs.SwissRoll() needed_attributes_testing(G) -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) - - -def run(): - """Run tests.""" - unittest.TextTestRunner(verbosity=2).run(suite) - -if __name__ == '__main__': - run() +suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index d4220c10..5f955a82 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -117,13 +117,5 @@ def test_distanz(x, y): # test_distanz(x, y) -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) - - -def run(): - """Run tests.""" - unittest.TextTestRunner(verbosity=2).run(suite) - -if __name__ == '__main__': - run() +suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) From 331cd21facc9cf56e9b289dc3cc837c1c8c94597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 13 Aug 2017 23:51:58 +0200 Subject: [PATCH 073/392] doc: show Barabasi-Albert and SBM --- doc/reference/graphs.rst | 6 ++++-- pygsp/graphs/barabasialbert.py | 11 +++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index 50aa54b0..a2236203 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -143,16 +143,18 @@ SwissRoll Barabasi-Albert --------------- -.. autoclass:: pygsp.graphs.barabasialbert +.. autoclass:: pygsp.graphs.BarabasiAlbert :undoc-members: :show-inheritance: + :members: Stochastic Block Model ---------------------- -.. autoclass:: pygsp.graphs.stochasticblockmodel +.. autoclass:: pygsp.graphs.StochasticBlockModel :undoc-members: :show-inheritance: + :members: Check Weights ------------- diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 2f97cc70..fa7a10f6 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -17,12 +17,11 @@ class BarabasiAlbert(Graph): First, m0 nodes are created. Then, nodes are added one by one. By lack of clarity, we take the liberty to create it as follows: - i) the m0 initial nodes are disconnected - ii) each node is connected to m of the older nodes with a probability - distribution depending of the node-degrees of the other nodes: - :: - p_n(i) = \frac{1 + k_i}{\sum_j{1 + k_j}} - :: + + 1. the m0 initial nodes are disconnected, + 2. each node is connected to m of the older nodes with a probability + distribution depending of the node-degrees of the other nodes, + :math:`p_n(i) = \frac{1 + k_i}{\sum_j{1 + k_j}}`. Parameters ---------- From fae5a55ab4ff66a3e0b0eefc60b0afd0ec3b27e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 00:06:37 +0200 Subject: [PATCH 074/392] move rescale_center to utils --- doc/reference/utils.rst | 10 ++++++++-- pygsp/graphs/swissroll.py | 35 ++--------------------------------- pygsp/utils.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/doc/reference/utils.rst b/doc/reference/utils.rst index 39a85fe6..94e711fc 100644 --- a/doc/reference/utils.rst +++ b/doc/reference/utils.rst @@ -1,5 +1,3 @@ -.. _utils-api: - ===== Utils ===== @@ -27,3 +25,11 @@ Distanz Resistance distance ------------------- .. autofunction:: pygsp.utils.resistance_distance + +Symmetrize +---------- +.. autofunction:: pygsp.utils.symmetrize + +Rescale and center +------------------ +.. autofunction:: pygsp.utils.rescale_center diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 201a10c8..8f89d944 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from . import Graph -from ..utils import distanz +from ..utils import distanz, rescale_center import numpy as np from math import sqrt, pi @@ -64,7 +64,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, self.x = x self.dim = dim - coords = self.rescale_center(x) + coords = rescale_center(x) dist = distanz(coords) W = np.exp(-np.power(dist, 2) / (2. * s**2)) W -= np.diag(np.diag(W)) @@ -75,34 +75,3 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, super(SwissRoll, self).__init__(W=W, coords=coords.T, plotting=plotting, gtype=gtype) - - def rescale_center(self, x): - r""" - Rescaling the dataset. - - Rescaling the dataset, previously and mainly used in the SwissRoll - graph. - - Parameters - ---------- - x : ndarray - Dataset to be rescaled. - - Returns - ------- - r : ndarray - Rescaled dataset. - - Examples - -------- - >>> from pygsp import utils - >>> utils.dummy(0, [1, 2, 3], True) - array([1, 2, 3]) - - """ - N = x.shape[1] - y = x - np.kron(np.ones((1, N)), np.mean(x, axis=1)[:, np.newaxis]) - c = np.amax(y) - r = y / c - - return r diff --git a/pygsp/utils.py b/pygsp/utils.py index 52bb30e7..7056bc26 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -221,3 +221,35 @@ def symmetrize(W, symmetrize_type='average'): return (W + W.T) / 2. # Resolve ambiguous entries else: raise ValueError("Unknown symmetrize type.") + + +def rescale_center(x): + r""" + Rescale and center data, e.g. embedding coordinates. + + Parameters + ---------- + x : ndarray + Data to be rescaled. + + Returns + ------- + r : ndarray + Rescaled data. + + Examples + -------- + >>> import pygsp + >>> x = np.array([[1, 6], [2, 5], [3, 4]]) + >>> pygsp.utils.rescale_center(x) + array([[-1. , 1. ], + [-0.6, 0.6], + [-0.2, 0.2]]) + + """ + N = x.shape[1] + y = x - np.kron(np.ones((1, N)), np.mean(x, axis=1)[:, np.newaxis]) + c = np.amax(y) + r = y / c + + return r From ce861c67008efffe3f5dee784d8854d43d5c57e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 00:52:54 +0200 Subject: [PATCH 075/392] fix all failed docstring tests --- pygsp/graphs/comet.py | 2 +- pygsp/graphs/graph.py | 14 +++++++------- pygsp/graphs/grid2d.py | 4 ++-- pygsp/graphs/gutils.py | 7 +++---- pygsp/graphs/lowstretchtree.py | 4 ++-- pygsp/graphs/nngraphs/twomoons.py | 16 ++++++++-------- pygsp/graphs/randomregular.py | 4 ++-- pygsp/operators/operator.py | 4 ++-- pygsp/operators/reduction.py | 6 ++++-- 9 files changed, 31 insertions(+), 30 deletions(-) diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 37b6e8bc..4139a535 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -20,7 +20,7 @@ class Comet(Graph): Examples -------- >>> from pygsp import graphs - >>> G = graphs.Comet() # (== graphs.Comet(Nv=32, k=12)) + >>> G = graphs.Comet() """ diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 59ea55c3..0674e8f6 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -181,10 +181,9 @@ def update_graph_attr(self, *args, **kwargs): 'the valid attributes, which are ' '{}').format(i, valid_attributes)) - from nngraphs import NNGraph + from .nngraphs import NNGraph if isinstance(self, NNGraph): super(NNGraph, self).__init__(**graph_attr) - else: super(type(self), self).__init__(**graph_attr) @@ -474,9 +473,10 @@ def extract_components(self): Examples -------- >>> from scipy import sparse - >>> from pygsp import graphs + >>> import pygsp >>> W = sparse.rand(10, 10, 0.2) - >>> G = graphs.Graph(W=W) + >>> W = pygsp.utils.symmetrize(W) + >>> G = pygsp.graphs.Graph(W=W) >>> components = G.extract_components() >>> has_sinks = 'sink' in components[0].info >>> sinks_0 = components[0].info['sink'] if has_sinks else [] @@ -486,12 +486,12 @@ def extract_components(self): self.is_directed() if self.A.shape[0] != self.A.shape[1]: - self.logger.error('Inconsistant shape to extract components. ' + self.logger.error('Inconsistent shape to extract components. ' 'Square matrix required.') return None if self.directed: - raise NotImplementedError('Focusing on non directed graphs first.') + raise NotImplementedError('Focusing on undirected graphs first.') graphs = [] @@ -499,7 +499,7 @@ def extract_components(self): # indices = [] # Assigned but never used while not visited.all(): - stack = set([np.nonzero(visited is False)[0][0]]) + stack = set(np.nonzero(~visited)[0]) comp = [] while len(stack): diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index f78658fc..d2a3733b 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -25,7 +25,7 @@ class Grid2d(Graph): Examples -------- >>> from pygsp import graphs - >>> G = graphs.Grid2d(shape=(32,) + >>> G = graphs.Grid2d(shape=(32,)) """ @@ -35,7 +35,7 @@ def __init__(self, shape=(3,), **kwargs): h = shape[0] try: w = shape[1] - except ValueError: + except IndexError: w = h except TypeError: h = shape diff --git a/pygsp/graphs/gutils.py b/pygsp/graphs/gutils.py index 6d8360d7..0baf979b 100644 --- a/pygsp/graphs/gutils.py +++ b/pygsp/graphs/gutils.py @@ -26,18 +26,17 @@ def check_weights(W): 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 + 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 zero value else false + True if the matrix diagonal has not only zeros else false Examples -------- >>> from scipy import sparse >>> from pygsp.graphs import gutils - >>> np.random.seed(42) - >>> W = sparse.rand(10,10,0.2) + >>> W = sparse.rand(10, 10, 0.2) >>> weights_chara = gutils.check_weights(W) """ diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index 8d15a954..8db28740 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -17,9 +17,9 @@ class LowStretchTree(Graph): Examples -------- - >>> from pygsp import graphs, plotting + >>> from pygsp import graphs >>> G = graphs.LowStretchTree(k=3) - >>> G.plot() + """ def __init__(self, k=6, **kwargs): diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index e0df8000..e11ddb34 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -15,9 +15,9 @@ class TwoMoons(NNGraph): ---------- moontype : string You have the freedom to chose if you want to create a standard - two_moons graph or a synthetised one (default is 'standard'). + two_moons graph or a synthesized one (default is 'standard'). 'standard' : Create a two_moons graph from a based graph. - 'synthetised' : Create a synthetised two_moon + 'synthesized' : Create a synthesized two_moon sigmag : float Variance of the distance kernel (default = 0.05) N : int @@ -32,11 +32,11 @@ class TwoMoons(NNGraph): -------- >>> from pygsp import graphs >>> G1 = graphs.TwoMoons(moontype='standard') - >>> G2 = graphs.TwoMoons(moontype='synthetised', N=1000, sigmad=0.1, d=1) + >>> G2 = graphs.TwoMoons(moontype='synthesized', N=1000, sigmad=0.1, d=1) """ - def create_arc_moon(N, sigmad, d, number): + def _create_arc_moon(self, N, sigmad, d, number): phi = np.random.rand(N, 1) * np.pi r = 1 rb = sigmad * np.random.normal(size=(N, 1)) @@ -64,17 +64,17 @@ def __init__(self, moontype='standard', sigmag=0.05, N=400, sigmad=0.07, d=0.5): self.labels = 2*(np.where(np.arange(1, N + 1).reshape(N, 1) > 1000, 1, 0) + 1) - elif moontype == 'synthetised': - gtype = 'Two Moons synthetised' + elif moontype == 'synthesized': + gtype = 'Two Moons synthesized' N1 = floor(N/2.) N2 = N - N1 # Moon 1 - Coordmoon1 = self.create_arc_moon(N1, sigmad, d, 1) + Coordmoon1 = self._create_arc_moon(N1, sigmad, d, 1) # Moon 2 - Coordmoon2 = self.create_arc_moon(N2, sigmad, d, 2) + Coordmoon2 = self._create_arc_moon(N2, sigmad, d, 2) Xin = np.concatenate((Coordmoon1, Coordmoon2)) self.labels = 2*(np.where(np.arange(1, N + 1).reshape(N, 1) > diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 88379977..8e1913c2 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -80,7 +80,7 @@ def createRandRegGraph(self, vertNum, deg, maxIter=10): Create a simple d-regular undirected graph. simple = without loops or double edges - d-reglar = each vertex is adjecent to d edges + d-regular = each vertex is adjacent to d edges Parameters ---------- @@ -127,7 +127,7 @@ def createRandRegGraph(self, vertNum, deg, maxIter=10): repetition = 1 # check that there are no loops nor parallel edges - while np.size(U) and repetition < matIter: + while np.size(U) and repetition < maxIter: edgesTested += 1 # print(progess) diff --git a/pygsp/operators/operator.py b/pygsp/operators/operator.py index d8bf6182..567d71ab 100644 --- a/pygsp/operators/operator.py +++ b/pygsp/operators/operator.py @@ -47,7 +47,7 @@ def grad(G, s): >>> import pygsp >>> import numpy as np >>> G = pygsp.graphs.Logo() - >>> s = np.random.rand(G.Ne) + >>> s = np.random.rand(G.N) >>> grad = pygsp.operators.grad(G, s) Parameters @@ -82,7 +82,7 @@ def grad_mat(G): # 1 call (above) -------- >>> import pygsp >>> G = pygsp.graphs.Logo() - >>> D = grad_mat(G) + >>> D = pygsp.operators.grad_mat(G) Parameters ---------- diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index f72eb32f..2b604eab 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -215,13 +215,15 @@ def graph_multiresolution(G, levels, **kwargs): Examples -------- + >>> import numpy as np >>> import pygsp >>> levels = 5 + >>> np.random.seed(42) >>> G = pygsp.graphs.Sensor(N=256) >>> Gs = pygsp.operators.graph_multiresolution(G, levels) >>> for idx in range(levels): - ... Gs[i].plotting['plot_name'] = 'Reduction level: {}'.format(idx) - ... Gs[i].plot() + ... Gs[idx].plotting['plot_name'] = 'Reduction level: {}'.format(idx) + ... Gs[idx].plot() """ # lambd = float(kwargs.pop('lambd', 0.025)) From 001f114e155c25b29540e11685f62af287bad4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 00:58:26 +0200 Subject: [PATCH 076/392] install with pip instead of setuptools --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index d15a1a81..b93368e9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,3 @@ -# Configuration file for automatic testing at travis-ci.org - dist: trusty # need glibc >= 2.17 for pyqt5 sudo: false @@ -19,7 +17,7 @@ addons: install: - pip install -U pip - pip install -U -r requirements.txt - - python setup.py install + - pip install . script: # - make lint From 1a39d593ced83bb649a8bf2e4efaeecf5c0a10b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 11:51:09 +0200 Subject: [PATCH 077/392] sensor graph: private methods --- pygsp/graphs/sensor.py | 75 +++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 9c887ba4..e887b226 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -34,7 +34,40 @@ class Sensor(Graph): """ - def get_nc_connection(self, W, param_nc): + def __init__(self, N=64, Nc=2, regular=False, n_try=50, + distribute=False, connected=True, **kwargs): + + self.Nc = Nc + self.regular = regular + self.n_try = n_try + self.distribute = distribute + + if connected: + for x in range(self.n_try): + W, coords = self._create_weight_matrix(N, distribute, + regular, Nc) + self.W = W + self.A = W > 0 + + if self.is_connected(): + break + + elif x == self.n_try - 1: + self.logger.warning('Graph is not connected.') + else: + W, coords = self._create_weight_matrix(N, distribute, regular, Nc) + + W = sparse.lil_matrix(W) + W = (W + W.T) / 2. + + gtype = 'regular sensor' if self.regular else 'sensor' + + plotting = {'limits': np.array([0, 1, 0, 1])} + + super(Sensor, self).__init__(W=W, coords=coords, gtype=gtype, + plotting=plotting, **kwargs) + + def _get_nc_connection(self, W, param_nc): Wtmp = W W = np.zeros(np.shape(W)) @@ -50,7 +83,7 @@ def get_nc_connection(self, W, param_nc): return W - def create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): + def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): XCoords = np.zeros((N, 1)) YCoords = np.zeros((N, 1)) @@ -78,46 +111,12 @@ def create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): W -= np.diag(np.diag(W)) if param_regular: - W = self.get_nc_connection(W, param_Nc) + W = self._get_nc_connection(W, param_Nc) else: - W2 = self.get_nc_connection(W, param_Nc) + W2 = self._get_nc_connection(W, param_Nc) W = np.where(W < T, 0, W) W = np.where(W2 > 0, W2, W) W = sparse.csc_matrix(W) return W, coords - - def __init__(self, N=64, Nc=2, regular=False, n_try=50, - distribute=False, connected=True, **kwargs): - - self.Nc = Nc - self.regular = regular - self.n_try = n_try - self.distribute = distribute - - if connected: - for x in range(self.n_try): - W, coords = self.create_weight_matrix(N, distribute, - regular, Nc) - self.W = W - self.A = W > 0 - - if self.is_connected(): - break - - elif x == self.n_try - 1: - self.logger.warning('Graph is not connected.') - else: - W, coords = self.create_weight_matrix(N, distribute, - regular, Nc) - - W = sparse.lil_matrix(W) - W = (W + W.T) / 2. - - gtype = 'regular sensor' if self.regular else 'sensor' - - plotting = {'limits': np.array([0, 1, 0, 1])} - - super(Sensor, self).__init__(W=W, coords=coords, gtype=gtype, - plotting=plotting, **kwargs) From 7c26764943321baea94cb8331e9c56154d0d5b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 11:52:06 +0200 Subject: [PATCH 078/392] random regular graph: move construction in init --- doc/references.bib | 8 +- pygsp/graphs/randomregular.py | 170 ++++++++++++++-------------------- 2 files changed, 75 insertions(+), 103 deletions(-) diff --git a/doc/references.bib b/doc/references.bib index cbb86e33..9b070a3e 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -162,7 +162,6 @@ @inproceedings{turk1994zippered organization={ACM} } - @ARTICLE{perraudin2014gspbox, author = {{Perraudin}, Nathana{\"e}l and {Paratte}, Johan and {Shuman}, David and {Kalofolias}, Vassilis and {Vandergheynst}, Pierre and {Hammond}, David K. }, @@ -183,3 +182,10 @@ @article{tremblay2016compressive journal={arXiv preprint arXiv:1602.02018}, year={2016} } + +@inproceedings{kim2003randomregulargraphs, + title={Generating random regular graphs}, + author={Kim, Jeong Han and Vu, Van H}, + booktitle={Proceedings of the thirty-fifth annual ACM symposium on Theory of computing}, + year={2003} +} diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 8e1913c2..a3fb514e 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -11,17 +11,31 @@ class RandomRegular(Graph): r""" - Create a random regular graphs + Create a random regular graph. - The random regular graph has the property that every nodes is connected to - 'k' other nodes. + The random regular graph has the property that every node is connected to + k other nodes. That graph is simple (without loops or double edges), + k-regular (each vertex is adjacent to k nodes), and undirected. Parameters ---------- N : int Number of nodes (default is 64) k : int - Number of connections of each nodes (default is 6) + Number of connections, or degree, of each node (default is 6) + maxIter : int + Maximum number of iterations (default is 10) + + Notes + ----- + The *pairing model* algorithm works as follows. First create n*d *half + edges*. Then repeat as long as possible: pick a pair of half edges and if + it's legal (doesn't create a loop nor a double edge) add it to the graph. + + References + ---------- + See :cite:`kim2003randomregulargraphs`. + This code has been adapted from matlab to python. Examples -------- @@ -30,98 +44,20 @@ class RandomRegular(Graph): """ - def isRegularGraph(self, A): - r""" - Troubleshoot a given regular graph. - - Parameters - ---------- - A : sparse matrix - - """ - warn = False - msg = 'The given matrix' - - # check if the sparse matrix is in a good format - if A.getformat() == 'lil' or \ - A.getformat() == 'dia' or \ - A.getformat() == 'bok': - A = A.tocsc() - - # check symmetry - tmp = A - A.T - if np.abs(tmp).sum() > 0: - warn = True - msg = '{} is not symmetric,'.format(msg) - - # check parallel edged - if A.max(axis=None) > 1: - warn = True - msg = '{} has parallel edges,'.format(msg) - - # check that d is d-regular - d_vec = A.sum(axis=0) - if np.min(d_vec) != np.max(d_vec): - warn = True - msg = '{} is not d-regular,'.format(msg) - - # check that g doesn't contain any self-loop - if A.diagonal().any(): - warn = True - msg = '{} has self loop.'.format(msg) - - if warn: - self.logger.warning('{}.'.format(msg[:-1])) - else: - self.logger.info('{} is ok.'.format(msg)) - - def createRandRegGraph(self, vertNum, deg, maxIter=10): - r""" - Create a simple d-regular undirected graph. - - simple = without loops or double edges - d-regular = each vertex is adjacent to d edges - - Parameters - ---------- - vertNum : int - Number of vertices - deg : int - The degree of each vertex - maxIter : int - The maximum number of iterations - - Returns - ------- - A : sparse - Representation of the graph - - Algorithm - --------- - "The pairing model": create n*d 'half edges'. - Repeat as long as possible: pick a pair of half edges - and if it's legal (doesn't create a loop nor a double edge) - add it to the graph - - References - ---------- - http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.67.7957&rep=rep1&type=pdf - (This code has been adapted from matlab to python) + def __init__(self, N=64, k=6, maxIter=10, **kwargs): + self.k = k - """ - n = vertNum - d = deg + self.logger = build_logger(__name__, **kwargs) # continue until a proper graph is formed - if (n * d) % 2 == 1: - raise ValueError("createRandRegGraph input err:\ - n*d must be even!") + if (N * k) % 2 == 1: + raise ValueError("input error: N*d must be even!") # a list of open half-edges - U = np.kron(np.ones((d)), np.arange(n)) + U = np.kron(np.ones(k), np.arange(N)) # the graphs adjacency matrix - A = sparse.lil_matrix(np.zeros((n, n))) + A = sparse.lil_matrix(np.zeros((N, N))) edgesTested = 0 repetition = 1 @@ -133,7 +69,7 @@ def createRandRegGraph(self, vertNum, deg, maxIter=10): # print(progess) if edgesTested % 5000 == 0: self.logger.debug("createRandRegGraph() progress: edges= " - "{}/{}.".format(edgesTested, n*d/2)) + "{}/{}.".format(edgesTested, n*k/2)) # chose at random 2 half edges i1 = floor(rd.random()*np.shape(U)[0]) @@ -144,11 +80,11 @@ def createRandRegGraph(self, vertNum, deg, maxIter=10): # check that there are no loops nor parallel edges if v1 == v2 or A[v1, v2] == 1: # restart process if needed - if edgesTested == n*d: + if edgesTested == N*k: repetition = repetition + 1 edgesTested = 0 - U = np.kron(np.ones((d)), np.arange(n)) - A = sparse.lil_matrix(np.zeros((n, n))) + U = np.kron(np.ones(k), np.arange(N)) + A = sparse.lil_matrix(np.zeros((N, N))) else: # add edge to graph A[v1, v2] = 1 @@ -158,17 +94,47 @@ def createRandRegGraph(self, vertNum, deg, maxIter=10): v = sorted([i1, i2]) U = np.concatenate((U[:v[0]], U[v[0] + 1:v[1]], U[v[1] + 1:])) - self.isRegularGraph(A) + super(RandomRegular, self).__init__(W=A, gtype="random_regular", + **kwargs) - return A + self.is_regular() - def __init__(self, N=64, k=6, **kwargs): - self.k = k + def is_regular(self): + r""" + Troubleshoot a given regular graph. - # Build the logger as createRandRegGraph need it - self.logger = build_logger(__name__, **kwargs) + """ + warn = False + msg = 'The given matrix' - W = self.createRandRegGraph(N, k) + # check if the sparse matrix is in a good format + A = self.A + if A.getformat() in ['lil', 'dia', 'bok']: + A = A.tocsc() - super(RandomRegular, self).__init__(W=W, gtype="random_regular", - **kwargs) + # check symmetry + tmp = A - A.T + if np.abs(tmp).sum() > 0: + warn = True + msg = '{} is not symmetric,'.format(msg) + + # check parallel edged + if A.max(axis=None) > 1: + warn = True + msg = '{} has parallel edges,'.format(msg) + + # check that d is d-regular + d_vec = A.sum(axis=0) + if np.min(d_vec) != np.max(d_vec): + warn = True + msg = '{} is not d-regular,'.format(msg) + + # check that g doesn't contain any self-loop + if A.diagonal().any(): + warn = True + msg = '{} has self loop.'.format(msg) + + if warn: + self.logger.warning('{}.'.format(msg[:-1])) + else: + self.logger.info('{} is ok.'.format(msg)) From 0df29603586c923c445fb578ecb0ebd3798ece35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 11:58:34 +0200 Subject: [PATCH 079/392] logo graph: fix typo --- pygsp/graphs/logo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 98ca8a8d..b955618b 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -8,7 +8,7 @@ class Logo(Graph): r""" - Create a graph with the GSP Logo. + Create a graph with the GSP logo. Examples -------- From a2884e086704f6f718606cca9c1b3463470daa11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 11:59:00 +0200 Subject: [PATCH 080/392] PointCloud: fix docstring --- pygsp/pointclouds/pointclouds.py | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/pygsp/pointclouds/pointclouds.py b/pygsp/pointclouds/pointclouds.py index e133c946..cf629d0a 100644 --- a/pygsp/pointclouds/pointclouds.py +++ b/pygsp/pointclouds/pointclouds.py @@ -8,13 +8,13 @@ class PointCloud(object): r""" - Load the parameters of models and the points. + Load model parameters and points. Parameters ---------- name : string The name of the point cloud to load. - Possible arguments : 'airfoil', 'bunny', 'david64', 'david500', 'logo', + Possible arguments: 'airfoil', 'bunny', 'david64', 'david500', 'logo', 'minnesota', two_moons'. max_dim : int The maximum dimensionality of the points (only valid for two_moons) @@ -22,26 +22,23 @@ class PointCloud(object): Returns ------- - The differents informations of the loaded PointCloud. + A PointCloud object with data and parameters. - - Examples - -------- - >>> from pygsp import pointclouds - >>> bunny = pointclouds.PointCloud('bunny') - >>> Xin = bunny.Xin - - - Note - ---- + Notes + ----- The bunny is the model from the Stanford Computer Graphics Laboratory (see reference). - References ---------- See :cite:`turk1994zippered` for more informations. + Examples + -------- + >>> from pygsp import pointclouds + >>> bunny = pointclouds.PointCloud('bunny') + >>> Xin = bunny.Xin + """ def __init__(self, pointcloudname, max_dim=2): @@ -104,9 +101,7 @@ def __init__(self, pointcloudname, max_dim=2): def plot(self, **kwargs): r""" - Plot the pointcloud. - - See plotting doc. + Plot the pointcloud. See :func:`pygsp.plotting.plot_pointcloud`. """ from pygsp import plotting plotting.plot_pointcloud(self, **kwargs) From 48a71d3a416b9862ccdb6c83096d38595a20b721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 12:03:00 +0200 Subject: [PATCH 081/392] NNGraph: fix comments --- pygsp/graphs/nngraphs/nngraph.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 5195f38c..c4287ecb 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -162,27 +162,14 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, else: raise ValueError('Unknown type : allowed values are knn, radius') - """ - Before, we were calling this same snippet in each of the conditional - statements above, so it's better to call it only once, after the - conditional statements have been evaluated. - """ 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 - """ - This conditional statement costs the same amount of computation as the - symmetrization itself, so it's better to simply call utils.symmetrize - no matter what - if np.abs(W - W.T).sum(): - W = utils.symmetrize(W, symmetrize_type=symmetrize_type) - else: - pass - """ + # Enforce symmetry. Note that checking symmetry with + # np.abs(W - W.T).sum() is as costly as the symmetrization itself. W = symmetrize(W, symmetrize_type=symmetrize_type) super(NNGraph, self).__init__(W=W, gtype=gtype, plotting=plotting, From fa3ef5542a1cf772b256e24b9ed9f576883f831d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 12:03:45 +0200 Subject: [PATCH 082/392] package import: alphabetical order --- pygsp/graphs/__init__.py | 2 +- pygsp/graphs/nngraphs/__init__.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index c5537163..b7f0c471 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -23,8 +23,8 @@ 'LowStretchTree', 'Minnesota', 'Path', - 'RandomRing', 'RandomRegular', + 'RandomRing', 'Ring', 'Sensor', 'StochasticBlockModel', diff --git a/pygsp/graphs/nngraphs/__init__.py b/pygsp/graphs/nngraphs/__init__.py index 636c8fa9..e7175b92 100644 --- a/pygsp/graphs/nngraphs/__init__.py +++ b/pygsp/graphs/nngraphs/__init__.py @@ -7,10 +7,10 @@ __all__ = ['NNGraph', 'Bunny', 'Cube', - 'Sphere', - 'TwoMoons', 'ImgPatches', - 'Grid2dImgPatches'] + 'Grid2dImgPatches', + 'Sphere', + 'TwoMoons'] for class_to_import in __all__: setattr(sys.modules[__name__], From 0205c7046a164bea97f79c9a54f9ee1fa6812c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 12:26:42 +0200 Subject: [PATCH 083/392] SBM graph: fix optional parameters --- pygsp/graphs/stochasticblockmodel.py | 73 ++++++++++++++++------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 110cd574..4bd5fddf 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -1,75 +1,85 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np from scipy import sparse +from . import Graph + class StochasticBlockModel(Graph): r""" Create a graph generated with the Stochastic Block Model. - The Stochastic Block Model graph is constructed by connecting nodes with a probability which depends on the cluster of the two nodes. - One can define the clustering association of each node, denoted by vector z, but also the probability matrix M. - All edge weights are equal to 1. By default, Mii > Mjk and nodes are uniformly clusterized. + The Stochastic Block Model graph is constructed by connecting nodes with a + probability which depends on the cluster of the two nodes. One can define + the clustering association of each node, denoted by vector z, but also the + probability matrix M. All edge weights are equal to 1. By default, Mii > + Mjk and nodes are uniformly clusterized. Parameters ---------- N : int Number of nodes (default is 1024) k : float - Number of classes - param : - Structure of optional parameter - z - the vector containing the association between nodes and classes. Default uniform. - M - the k by k matrix containing the probability of connecting nodes based on their class belonging. Default using p and q. - p - the diagonal value(s) for the matrix M. If scalar they all have the same value. Otherwise expect a 1xk vector. Default p = 0.7. - q - the offdiagonal value(s) for the matrix M. If scalar they all have the same value. Otherwise expect a kxk matrix, diagonal will be discarded. Default q = 0.3/k. - undirected - flag to force the graph to be undirected. Default True. - no_self_loop - flag to force the graph to have no self loop. Default True. + Number of classes (default is 5) + z : array_like + the vector of length N containing the association between nodes and + classes. Default uniform. + M : array_like + the k by k matrix containing the probability of connecting nodes based + on their class belonging. Default using p and q. + p : float or array_like + the diagonal value(s) for the matrix M. If scalar they all have the + same value. Otherwise expect a length k vector. Default p = 0.7. + q : float or array_like + the off-diagonal value(s) for the matrix M. If scalar they all have the + same value. Otherwise expect a k x k matrix, diagonal will be + discarded. Default q = 0.3/k. + undirected : bool + force the graph to be undirected. Default True. + no_self_loop : bool + force the graph to have no self loop. Default True. Examples -------- >>> from pygsp import graphs - >>> G = graphs.StochasticBlockModel(1024, 5) + >>> G = graphs.StochasticBlockModel(N=1024, k=5) - Author: Lionel Martin """ - def __init__(self, N=1024, k=5, **kwargs): - - undirected = bool(kwargs.pop('undirected', True)) - no_self_loop = bool(kwargs.pop('no_self_loop', True)) + def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, + undirected=True, no_self_loop=True, **kwargs): - z = kwargs.pop('z', np.random.randint(0, k, N)) - M = kwargs.get('M', np.ones((k, k))) + if z is None: + z = np.random.randint(0, k, N) - if 'M' not in kwargs: - p = kwargs.pop('p', 0.7) + if M is None: if isinstance(p, float): p *= np.ones(k) elif isinstance(p, list): p = np.array(p) if p.shape != (k, ): - raise ValueError('Optional parameter p is neither a scalar nor a vector of size k.') + raise ValueError('Optional parameter p is neither a scalar nor' + 'a vector of length k.') - q = kwargs.pop('q', 0.3/k) + if q is None: + q = 0.3 / k if isinstance(q, float): q *= np.ones((k, k)) elif isinstance(q, list): q = np.array(q) if q.shape != (k, k): - raise ValueError('Optional parameter q is neither a scalar nor a matrix of size kxk.') + raise ValueError('Optional parameter q is neither a scalar nor' + 'a matrix of size k x k.') M = q M.flat[::k+1] = p # edit the diagonal terms nb_row, nb_col = 0, 0 csr_data, csr_i, csr_j = [], [], [] - for i in range(N**2): + for _ in range(N**2): if nb_row != nb_col or not no_self_loop: if nb_row > nb_col or not undirected: if np.random.rand() < M[z[nb_row], z[nb_col]]: @@ -87,11 +97,12 @@ def __init__(self, N=1024, k=5, **kwargs): if undirected: W = W + W.T - if not no_self_loop: # avoid doubling the self loops with the sum above + if not no_self_loop: + # avoid doubling the self loops with the above sum W[np.arange(N), np.arange(N)] /= 2. self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} - super(StochasticBlockModel, self).__init__(gtype='StochasticBlockModel', - W=W, **kwargs) + gtype = 'StochasticBlockModel' + super(StochasticBlockModel, self).__init__(gtype=gtype, W=W, **kwargs) From 82b6124a9c0700d0f0b2ffa50ab1bd8fc0dd78c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 12:45:05 +0200 Subject: [PATCH 084/392] floor division (raised float index error in python 2) --- pygsp/graphs/barabasialbert.py | 2 -- pygsp/graphs/erdosrenyi.py | 2 -- pygsp/graphs/nngraphs/cube.py | 3 +-- pygsp/graphs/nngraphs/twomoons.py | 3 +-- pygsp/graphs/randomregular.py | 6 ++---- pygsp/graphs/ring.py | 3 +-- 6 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index fa7a10f6..88d35367 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -5,8 +5,6 @@ import numpy as np from scipy import sparse -import random as rd -from math import floor class BarabasiAlbert(Graph): diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 748b7ec2..edbf09ad 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -5,8 +5,6 @@ import numpy as np from scipy import sparse -import random as rd -from math import floor class ErdosRenyi(Graph): diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 1e1650bf..d0f0d35b 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -3,7 +3,6 @@ from . import NNGraph import numpy as np -from math import floor class Cube(NNGraph): @@ -44,7 +43,7 @@ def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling="random", **kwargs): pts = np.random.rand(self.nb_pts, self.nb_pts) elif self.nb_dim == 3: - n = floor(self.nb_pts/6.) + n = self.nb_pts // 6 pts = np.zeros((n*6, 3)) pts[:n, 1:] = np.random.rand(n, 2) diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index e11ddb34..29f29f6f 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -4,7 +4,6 @@ from ...pointclouds import PointCloud import numpy as np -from math import floor class TwoMoons(NNGraph): @@ -67,7 +66,7 @@ def __init__(self, moontype='standard', sigmag=0.05, N=400, sigmad=0.07, d=0.5): elif moontype == 'synthesized': gtype = 'Two Moons synthesized' - N1 = floor(N/2.) + N1 = N // 2 N2 = N - N1 # Moon 1 diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index a3fb514e..9e66607b 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -5,8 +5,6 @@ import numpy as np from scipy import sparse -import random as rd -from math import floor class RandomRegular(Graph): @@ -72,8 +70,8 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): "{}/{}.".format(edgesTested, n*k/2)) # chose at random 2 half edges - i1 = floor(rd.random()*np.shape(U)[0]) - i2 = floor(rd.random()*np.shape(U)[0]) + i1 = np.random.randint(0, np.shape(U)[0]) + i2 = np.random.randint(0, np.shape(U)[0]) v1 = U[i1] v2 = U[i2] diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 436c7e4b..1eb78551 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -4,7 +4,6 @@ import numpy as np from scipy import sparse -from math import floor class Ring(Graph): @@ -40,7 +39,7 @@ def __init__(self, N=64, k=1, **kwargs): j_inds = np.zeros((2 * num_edges)) tmpN = np.arange(N, dtype=int) - for i in range(min(k, floor((N - 1)/2.))): + for i in range(min(k, (N - 1) // 2)): i_inds[2*i * N + tmpN] = tmpN j_inds[2*i * N + tmpN] = np.remainder(tmpN + i + 1, N) i_inds[(2*i + 1)*N + tmpN] = np.remainder(tmpN + i + 1, N) From 07d1ae5d1f7330a54457f9bd616d6e5c5f6ec154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 14:06:52 +0200 Subject: [PATCH 085/392] graph_multiresolution: correct handling of optional parameters --- doc/tutorials/demo_pyramid.rst | 2 +- pygsp/operators/reduction.py | 59 ++++++++++++++++------------------ 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/demo_pyramid.rst index 49dcf22f..3166dbbb 100644 --- a/doc/tutorials/demo_pyramid.rst +++ b/doc/tutorials/demo_pyramid.rst @@ -17,7 +17,7 @@ For this demo we will be using a Sensor graph with 512 nodes. The function graph_multiresolution computes the graph pyramid for you: >>> levels = 5 ->>> Gs = graph_multiresolution(G, levels, epsilon=0.1, sparsify=False) +>>> Gs = graph_multiresolution(G, levels, sparsify=False) Next, we will compute the fourier basis of our different graph layers: >>> for gr in Gs: diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 2b604eab..54328ba1 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -168,7 +168,10 @@ def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): return green_kernel.analysis(f_interpolated, order=order, **kwargs) -def graph_multiresolution(G, levels, **kwargs): +def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, + downsampling_method='largest_eigenvector', + reduction_method='kron', compute_full_eigen=False, + reg_eps=0.005): r""" Compute a pyramid of graphs using the kron reduction. @@ -186,32 +189,31 @@ def graph_multiresolution(G, levels, **kwargs): The graph to reduce. levels : int Number of level of decomposition - params : dict - lambd : float - Stability parameter. It adds self loop to the graph to give the - algorithm some stability (default = 0.025). [UNUSED?!] - sparsify : bool - To perform a spectral sparsification step immediately after - the graph reduction (default is True). - sparsify_eps : float - Parameter epsilon used in the spectral sparsification - (default is min(10/sqrt(G.N),.3)). - downsampling_method: string - The graph downsampling method (default is 'largest_eigenvector'). - reduction_method : string - The graph reduction method (default is 'kron') - compute_full_eigen : bool - To also compute the graph Laplacian eigenvalues and eigenvectors - for every graph in the multiresolution sequence (default is False). - reg_eps : float - The regularized graph Laplacian is $\bar{L}=L+\epsilon I$. - A smaller epsilon may lead to better regularization, but will also - require a higher order Chebyshev approximation. (default is 0.005) + lambd : float + Stability parameter. It adds self loop to the graph to give the + algorithm some stability (default = 0.025). [UNUSED?!] + sparsify : bool + To perform a spectral sparsification step immediately after + the graph reduction (default is True). + sparsify_eps : float + Parameter epsilon used in the spectral sparsification + (default is min(10/sqrt(G.N),.3)). + downsampling_method: string + The graph downsampling method (default is 'largest_eigenvector'). + reduction_method : string + The graph reduction method (default is 'kron') + compute_full_eigen : bool + To also compute the graph Laplacian eigenvalues and eigenvectors + for every graph in the multiresolution sequence (default is False). + reg_eps : float + The regularized graph Laplacian is :math:`\bar{L}=L+\epsilon I`. + A smaller epsilon may lead to better regularization, but will also + require a higher order Chebyshev approximation. (default is 0.005) Returns ------- - Gs : ndarray - The graph layers. + Gs : list + A list of graph layers. Examples -------- @@ -226,13 +228,8 @@ def graph_multiresolution(G, levels, **kwargs): ... Gs[idx].plot() """ - # lambd = float(kwargs.pop('lambd', 0.025)) - sparsify = bool(kwargs.pop('sparsify', True)) - sparsify_eps = float(kwargs.pop('sparsify_eps', min(10./np.sqrt(G.N), 0.3))) - downsampling_method = kwargs.pop('downsampling_method', 'largest_eigenvector') - reduction_method = kwargs.pop('downsampling_method', 'kron') - compute_full_eigen = bool(kwargs.pop('compute_full_eigen', False)) - reg_eps = float(kwargs.pop('reg_eps', 0.005)) + if sparsify_eps is None: + sparsify_eps = min(10. / np.sqrt(G.N), 0.3) if compute_full_eigen: if not hasattr(G, 'e') or not hasattr(G, 'U'): From 3100722cb887c531ab6cf839c4c7a890068e80fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 15:24:27 +0200 Subject: [PATCH 086/392] graph_multiresolution: compute full eigen to avoid ARPACK convergence error --- pygsp/operators/reduction.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 54328ba1..bc9c342c 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -217,12 +217,11 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, Examples -------- - >>> import numpy as np >>> import pygsp >>> levels = 5 - >>> np.random.seed(42) - >>> G = pygsp.graphs.Sensor(N=256) - >>> Gs = pygsp.operators.graph_multiresolution(G, levels) + >>> G = pygsp.graphs.Sensor(N=512) + >>> G.compute_fourier_basis() + >>> Gs = pygsp.operators.graph_multiresolution(G, levels, sparsify=False) >>> for idx in range(levels): ... Gs[idx].plotting['plot_name'] = 'Reduction level: {}'.format(idx) ... Gs[idx].plot() From a8ffd47ae17eed70bbe85af869523ff408f03659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:27:39 +0200 Subject: [PATCH 087/392] graph: typo --- pygsp/graphs/graph.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0674e8f6..e015879b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -540,11 +540,9 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, 'G.compute_fourier_basis()' computes a full eigendecomposition of the graph Laplacian G.L: - .. L = U Lambda U* - .. math:: {\cal L} = U \Lambda U^* - where $\Lambda$ is a diagonal matrix of the Laplacian eigenvalues. + where :math:`\Lambda` is a diagonal matrix of eigenvalues. *G.e* is a column vector of length *G.N* containing the Laplacian eigenvalues. The largest eigenvalue is stored in *G.lmax*. From b0c73aa095d74b50da50e351f9b038e7f1aa8ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:29:11 +0200 Subject: [PATCH 088/392] pyramid tutorial: correct code presentation --- doc/tutorials/demo_pyramid.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/demo_pyramid.rst index 3166dbbb..8f1a307c 100644 --- a/doc/tutorials/demo_pyramid.rst +++ b/doc/tutorials/demo_pyramid.rst @@ -20,11 +20,13 @@ The function graph_multiresolution computes the graph pyramid for you: >>> Gs = graph_multiresolution(G, levels, sparsify=False) Next, we will compute the fourier basis of our different graph layers: + >>> for gr in Gs: ... gr.compute_fourier_basis() Those that were already computed are returning with an error, meaning that nothing happened. Let's now create two signals and a filter, resp f, f2 and g: + >>> f = np.ones((G.N)) >>> f[np.arange(G.N//2)] = -1 >>> f = f + 10*Gs[0].U[:, 7] @@ -36,14 +38,17 @@ Let's now create two signals and a filter, resp f, f2 and g: We will run the analysis of the two signals on the pyramid and obtain a coarse approximation for each layer, with decreasing number of nodes. Additionally, we will also get prediction errors at each node at every layer. + >>> ca, pe = pyramid_analysis(Gs, f, h_filters=g) >>> ca2, pe2 = pyramid_analysis(Gs, f2, h_filters=g) Given the pyramid, the coarsest approximation and the prediction errors, we will now reconstruct the original signal on the full graph. + >>> f_pred, _ = pyramid_synthesis(Gs, ca[levels], pe) >>> f_pred2, _ = pyramid_synthesis(Gs, ca2[levels], pe2) Here are the final errors for each signal after reconstruction. + >>> err = np.linalg.norm(f_pred-f)/np.linalg.norm(f) >>> err2 = np.linalg.norm(f_pred2-f2)/np.linalg.norm(f2) >>> assert (err < 1e-10) & (err2 < 1e-10) From 143a9913fabfdc81a1db5ddc69da82d599be0cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:33:00 +0200 Subject: [PATCH 089/392] tutorials: fix graph tv --- doc/tutorials/demo_graph_tv.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/tutorials/demo_graph_tv.rst b/doc/tutorials/demo_graph_tv.rst index 3d2deaa7..9ae3c010 100644 --- a/doc/tutorials/demo_graph_tv.rst +++ b/doc/tutorials/demo_graph_tv.rst @@ -9,7 +9,7 @@ Reconstruction of missing sample on a graph using TV In this demo, we try to reconstruct missing sample of a piece-wise smooth signal on a graph. To do so, we will minimize the well-known TV norm defined on the graph. -For this example, you need the pyunlocbox. You can download it from https://github.com/epfl-lts2/pyunlocbox and installing it. +For this example, you will need the `pyunlocbox `_. We express the recovery problem as a convex optimization problem of the following form: @@ -82,14 +82,14 @@ mask and addition of noise. More than half of the vertices are set to 0. .. >>> .. >>> # Setting the function ftv .. >>> f2 = pyunlocbox.functions.func() -.. >>> f1._prox = lambda x, T: operators.prox_tv(x, T, G, verbose=verbose-1) -.. >>> f1._eval = lambda x: operators.norm_tv(G, x) +.. >>> f2._prox = lambda x, T: operators.prox_tv(x, T, G, verbose=verbose-1) +.. >>> f2._eval = lambda x: operators.norm_tv(G, x) .. >>> .. >>> # Solve the problem .. >>> solver = pyunlocbox.solvers.douglas_rachford() .. >>> param = {'x0': depleted_graph_value, 'solver': solver, 'atol': 1e-7, 'maxit': 50, 'verbosity': 'LOW'} .. >>> # With prox_tv -.. >>> ret = pyunlocboxsolvers.solve([f2, f1], **param) +.. >>> ret = pyunlocbox.solvers.solve([f2, f1], **param) .. >>> prox_tv_reconstructed_graph = ret['sol'] .. >>> .. >>> plotting.plt_plot_signal(G, prox_tv_reconstructed_graph, show_edges=True, savefig=True, plot_name='doc/tutorials/img/tv_recons_signal') @@ -109,7 +109,7 @@ In this case, we solve: The result is presented as following: .. >>> # Solve the problem with the same solver as before but with a prox_tik function -.. >>> ret2 = pyunlocbox.solvers.solve([f3, f1], **param) +.. >>> ret = pyunlocbox.solvers.solve([f3, f1], **param) .. >>> prox_tik_reconstructed_graph = ret['sol'] .. >>> .. >>> plotting.plt_plot_signal(G, prox_tik_reconstructed_graph, show_edges=True, savefig=True, plot_name='doc/tutorials/img/tik_recons_signal') From 2d1b7c644f7763163369064322e3702cb9cdc4ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:34:13 +0200 Subject: [PATCH 090/392] tutorials: rename --- doc/tutorials/demo_graph_tv.rst | 8 +++----- doc/tutorials/demo_pyramid.rst | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/doc/tutorials/demo_graph_tv.rst b/doc/tutorials/demo_graph_tv.rst index 9ae3c010..d018470e 100644 --- a/doc/tutorials/demo_graph_tv.rst +++ b/doc/tutorials/demo_graph_tv.rst @@ -1,12 +1,10 @@ -===================================================== -Reconstruction of missing samples on a graph using TV -===================================================== +=============================================== +Reconstruction of missing samples with graph TV +=============================================== Description ----------- -Reconstruction of missing sample on a graph using TV - In this demo, we try to reconstruct missing sample of a piece-wise smooth signal on a graph. To do so, we will minimize the well-known TV norm defined on the graph. For this example, you will need the `pyunlocbox `_. diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/demo_pyramid.rst index 8f1a307c..60494b9a 100644 --- a/doc/tutorials/demo_pyramid.rst +++ b/doc/tutorials/demo_pyramid.rst @@ -1,6 +1,6 @@ -============================================ -Graph multiresolution: reduction and pyramid -============================================ +=================================== +Graph multiresolution: Kron pyramid +=================================== In this demonstration file, we show how to reduce a graph using the PyGSP. Then we apply the pyramid to simple signal. To start open a python shell (IPython is recommended here) and import the required packages. You would probably also import numpy as you will need it to create matrices and arrays. From 053ecd0888a54da114af16f0d2cf6c36999c1c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:35:23 +0200 Subject: [PATCH 091/392] tutorials: rename --- doc/tutorials/{demo_graph_tv.rst => graph_tv.rst} | 0 doc/tutorials/index.rst | 8 ++++---- doc/tutorials/{demo.rst => intro.rst} | 0 doc/tutorials/{demo_pyramid.rst => pyramid.rst} | 0 doc/tutorials/{demo_wavelet.rst => wavelet.rst} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename doc/tutorials/{demo_graph_tv.rst => graph_tv.rst} (100%) rename doc/tutorials/{demo.rst => intro.rst} (100%) rename doc/tutorials/{demo_pyramid.rst => pyramid.rst} (100%) rename doc/tutorials/{demo_wavelet.rst => wavelet.rst} (100%) diff --git a/doc/tutorials/demo_graph_tv.rst b/doc/tutorials/graph_tv.rst similarity index 100% rename from doc/tutorials/demo_graph_tv.rst rename to doc/tutorials/graph_tv.rst diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index e3c02d8f..c87d057a 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -8,7 +8,7 @@ to solve some real problems. .. toctree:: :maxdepth: 1 - demo - demo_wavelet - demo_graph_tv - demo_pyramid + intro + wavelet + graph_tv + pyramid diff --git a/doc/tutorials/demo.rst b/doc/tutorials/intro.rst similarity index 100% rename from doc/tutorials/demo.rst rename to doc/tutorials/intro.rst diff --git a/doc/tutorials/demo_pyramid.rst b/doc/tutorials/pyramid.rst similarity index 100% rename from doc/tutorials/demo_pyramid.rst rename to doc/tutorials/pyramid.rst diff --git a/doc/tutorials/demo_wavelet.rst b/doc/tutorials/wavelet.rst similarity index 100% rename from doc/tutorials/demo_wavelet.rst rename to doc/tutorials/wavelet.rst From 7ff1c3ba613f7a223c8a5a240146e85650f4676a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 16:58:23 +0200 Subject: [PATCH 092/392] check_weights: move from utils to graph method --- doc/reference/graphs.rst | 4 --- pygsp/graphs/__init__.py | 1 - pygsp/graphs/graph.py | 67 +++++++++++++++++++++++++++++++++--- pygsp/graphs/gutils.py | 72 --------------------------------------- pygsp/tests/test_utils.py | 2 +- 5 files changed, 64 insertions(+), 82 deletions(-) delete mode 100644 pygsp/graphs/gutils.py diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index a2236203..83749e45 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -155,7 +155,3 @@ Stochastic Block Model :undoc-members: :show-inheritance: :members: - -Check Weights -------------- -.. autofunction:: pygsp.graphs.gutils.check_weights diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index b7f0c471..c9c2886f 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -40,4 +40,3 @@ class_to_import)) from .nngraphs import * -from .gutils import * diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e015879b..95a0d0dd 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- -from ..utils import build_logger -from .gutils import check_weights +import math +from collections import Counter import numpy as np import scipy as sp from scipy import sparse -from collections import Counter +from ..utils import build_logger class Graph(object): @@ -87,7 +87,7 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.N = shapes[0] self.W = sparse.lil_matrix(W) - check_weights(self.W) + self.check_weights() self.A = self.W > 0 self.Ne = self.W.nnz @@ -123,6 +123,65 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', if isinstance(plotting, dict): self.plotting.update(plotting) + 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 + -------- + >>> import numpy as np + >>> from pygsp import graphs + >>> W = np.arange(4).reshape(2, 2) + >>> G = graphs.Graph(W) + >>> G.check_weights() # doctest: +NORMALIZE_WHITESPACE + {'has_inf_val': False, 'has_nan_value': False, + 'is_not_square': False, 'diag_is_not_zero': True} + + """ + + has_inf_val = False + diag_is_not_zero = False + is_not_square = False + has_nan_value = False + + if math.isinf(self.W.sum()): + self.logger.warning("GSP_TEST_WEIGHTS: There is an infinite " + "value in the weight matrix") + has_inf_val = True + + if abs(self.W.diagonal()).sum() != 0: + self.logger.warning("GSP_TEST_WEIGHTS: 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("GSP_TEST_WEIGHTS: The weight matrix is " + "not square!") + is_not_square = True + + if math.isnan(self.W.sum()): + self.logger.warning("GSP_TEST_WEIGHTS: There is an 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 update_graph_attr(self, *args, **kwargs): r""" Recompute some attribute of the graph. diff --git a/pygsp/graphs/gutils.py b/pygsp/graphs/gutils.py deleted file mode 100644 index 0baf979b..00000000 --- a/pygsp/graphs/gutils.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- - -from .. import utils - -import numpy as np -import scipy as sp -from scipy import sparse -from math import isinf, isnan - -logger = utils.build_logger(__name__) - - -def check_weights(W): - r""" - Check the characteristics of the weights matrix. - - Parameters - ---------- - W : weights matrix - The weights matrix to check - - 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 - -------- - >>> from scipy import sparse - >>> from pygsp.graphs import gutils - >>> W = sparse.rand(10, 10, 0.2) - >>> weights_chara = gutils.check_weights(W) - - """ - - has_inf_val = False - diag_is_not_zero = False - is_not_square = False - has_nan_value = False - - if isinf(W.sum()): - logger.warning("GSP_TEST_WEIGHTS: There is an infinite " - "value in the weight matrix") - has_inf_val = True - - if abs(W.diagonal()).sum() != 0: - logger.warning("GSP_TEST_WEIGHTS: The main diagonal of " - "the weight matrix is not 0!") - diag_is_not_zero = True - - if W.get_shape()[0] != W.get_shape()[1]: - logger.warning("GSP_TEST_WEIGHTS: The weight matrix is " - "not square!") - is_not_square = True - - if isnan(W.sum()): - logger.warning("GSP_TEST_WEIGHTS: There is an 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} diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 5f955a82..eb3d4777 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -87,7 +87,7 @@ def test_estimate_lmax(G, lmax): self.assertTrue(lmax <= G.lmax and G.lmax <= 1.02 * lmax) def test_check_weights(G, w_c): - self.assertEqual(graphs.gutils.check_weights(G.W), w_c) + self.assertEqual(G.check_weights(), w_c) def test_is_connected(G, is_conn, **kwargs): self.assertEqual(G.is_connected(), is_conn) From 1b035da0c4c2efaabe4d8ee6c43b63b475e4964f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 17:25:34 +0200 Subject: [PATCH 093/392] docstrings: no variable for parameters --- pygsp/graphs/nngraphs/cube.py | 3 +-- pygsp/graphs/nngraphs/sphere.py | 3 +-- pygsp/graphs/torus.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index d0f0d35b..37b34c16 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -24,8 +24,7 @@ class Cube(NNGraph): Examples -------- >>> from pygsp import graphs - >>> radius = 5 - >>> G = graphs.Cube(radius=radius) + >>> G = graphs.Cube(radius=5) """ diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 269d24ac..e3084c8d 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -24,8 +24,7 @@ class Sphere(NNGraph): Examples -------- >>> from pygsp import graphs - >>> radius = 5 - >>> G = graphs.Sphere(radius=radius) + >>> G = graphs.Sphere(radius=5) """ diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 39abbdc2..b9a46628 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -20,8 +20,7 @@ class Torus(Graph): Examples -------- >>> from pygsp import graphs - >>> Nv = 32 - >>> G = graphs.Torus(Nv=Nv) + >>> G = graphs.Torus(Nv=32) References ---------- From 8ff6b6453666d942f2448c3bd285f958a1026566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 17:34:07 +0200 Subject: [PATCH 094/392] docstrings: dict are not ordered... --- 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 95a0d0dd..ccd72579 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -146,9 +146,10 @@ def check_weights(self): >>> from pygsp import graphs >>> W = np.arange(4).reshape(2, 2) >>> G = graphs.Graph(W) - >>> G.check_weights() # doctest: +NORMALIZE_WHITESPACE - {'has_inf_val': False, 'has_nan_value': False, - 'is_not_square': False, 'diag_is_not_zero': True} + >>> cw = G.check_weights() + >>> cw == {'has_inf_val': False, 'has_nan_value': False, + ... 'is_not_square': False, 'diag_is_not_zero': True} + True """ From b42be8eb739d7e811a9649b89e1a075076597567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 14 Aug 2017 18:01:46 +0200 Subject: [PATCH 095/392] filters: normalize docstrings and imports --- pygsp/filters/abspline.py | 17 +++++------------ pygsp/filters/expwin.py | 15 +++++---------- pygsp/filters/gabor.py | 25 ++++++++----------------- pygsp/filters/halfcosine.py | 16 +++++----------- pygsp/filters/heat.py | 16 +++++----------- pygsp/filters/held.py | 23 +++++++++-------------- pygsp/filters/itersine.py | 19 +++++++------------ pygsp/filters/mexicanhat.py | 17 +++++------------ pygsp/filters/meyer.py | 21 +++++++-------------- pygsp/filters/papadakis.py | 29 ++++++++++++----------------- pygsp/filters/regular.py | 30 +++++++++++------------------- pygsp/filters/simoncelli.py | 28 ++++++++++++---------------- pygsp/filters/simpletf.py | 17 +++++------------ pygsp/filters/warpedtranslates.py | 22 +++++++++++----------- 14 files changed, 107 insertions(+), 188 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index fda60e92..9bc9afff 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -1,21 +1,18 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np from scipy import optimize -from math import exp + +from . import Filter class Abspline(Filter): r""" - Abspline Filterbank - - Inherits its methods from Filters + Abspline filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters from 0 to lmax (default = 6) lpfactor : int @@ -26,10 +23,6 @@ class Abspline(Filter): Vector of scale to be used (Initialized by default at the value of the log scale) - Returns - ------- - out : Abspline - Examples -------- >>> from pygsp import graphs, filters @@ -86,7 +79,7 @@ def kernel_abspline3(x, alpha, beta, t1, t2): lminfac = .4 * G.lmin - self.g = [lambda x: 1.2 * exp(-1) * gl(x / lminfac)] + self.g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] for i in range(0, Nf - 1): self.g.append(lambda x, ind=i: gb(self.t[ind] * x)) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 72c63898..3c9421af 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -1,28 +1,22 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np +from . import Filter + class Expwin(Filter): r""" - Expwin Filterbank - - Inherits its methods from Filters + Expwin filterbank Parameters ---------- - G : Graph + G : graph bmax : float Maximum relative band (default = 0.2) a : int Slope parameter (default = 1) - Returns - ------- - out : Expwin - Examples -------- >>> from pygsp import graphs, filters @@ -30,6 +24,7 @@ class Expwin(Filter): >>> F = filters.Expwin(G) """ + def __init__(self, G, bmax=0.2, a=1., **kwargs): super(Expwin, self).__init__(G, **kwargs) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 8ea37672..5bc78b4c 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -1,31 +1,24 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np +from . import Filter -class Gabor(Filter): - """ - Gabor Filterbank - Inherits its methods from Filters +class Gabor(Filter): + r""" + Gabor filterbank Parameters ---------- - G : Graph - Graph structur + G : graph k : lambda function kernel - Returns - ------- - g : Gabor - - Note - ---- + Notes + ----- This function create a filterbank with the kernel *k*. Every filter is - centered in a different frequency + centered in a different frequency. Examples -------- @@ -34,8 +27,6 @@ class Gabor(Filter): >>> k = lambda x: x/(1.-x) >>> F = filters.Gabor(G, k); - Author: Nathanael Perraudin - Date : 13 June 2014 """ def __init__(self, G, k, **kwargs): super(Gabor, self).__init__(G, **kwargs) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index b17287f0..dc809cb1 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -1,25 +1,19 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class HalfCosine(Filter): r""" - HalfCosine Filterbank - - Inherits its methods from Filters + HalfCosine filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters from 0 to lmax (default = 6) - Returns - ------- - out : HalfCosine Examples -------- @@ -37,7 +31,7 @@ def __init__(self, G, Nf=6, **kwargs): dila_fact = G.lmax * (3./(Nf - 2)) - main_window = lambda x: np.multiply(np.multiply((.5 + .5*np.cos(2.*pi*(x/dila_fact - 1./2))), (x >= 0)), (x <= dila_fact)) + main_window = lambda x: np.multiply(np.multiply((.5 + .5*np.cos(2.*np.pi*(x/dila_fact - 1./2))), (x >= 0)), (x <= dila_fact)) g = [] diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index f18ad319..5f1f9783 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -1,30 +1,24 @@ # -*- coding: utf-8 -*- -from . import Filter - -from numpy import linalg import numpy as np +from numpy import linalg + +from . import Filter class Heat(Filter): r""" - Heat Filterbank - - Inherits its methods from Filters + Heat filterbank Parameters ---------- - G : Graph + G : graph tau : int or list of ints Scaling parameter. (default = 10) normalize : bool Normalize the kernel (works only if the eigenvalues are present in the graph). (default = 0) - Returns - ------- - out : Heat - Examples -------- >>> from pygsp import graphs, filters diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index d06942ca..94fa9610 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Held(Filter): r""" - Held Filterbank - - Inherits its methods from Filters + Held filterbank This function create a parseval filterbank of :math:`2` filters. - The low-pass filter is defined by a function :math:`f_l(x)` + The low-pass filter is defined by the function - .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ \sin\left(2\pi\mu\left(\frac{x}{8a}\right)\right) & \mbox{if }a2a \end{cases} + .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ + \sin\left(2\pi\mu\left(\frac{x}{8a}\right)\right) & \mbox{if }a2a \end{cases} with @@ -25,15 +24,11 @@ class Held(Filter): Parameters ---------- - G : Graph + G : graph a : float See equation above for this parameter The spectrum is scaled between 0 and 2 (default = 2/3) - Returns - ------- - out : Held - Examples -------- >>> from pygsp import graphs, filters @@ -62,7 +57,7 @@ def held(val, a): r3ind = (val >= l2) y[r1ind] = 1 - y[r2ind] = np.sin(2*pi*mu(val[r2ind]/(8.*a))) + y[r2ind] = np.sin(2*np.pi*mu(val[r2ind]/(8.*a))) y[r3ind] = 0 return y diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index b571d6f7..668818ff 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -1,30 +1,25 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Itersine(Filter): r""" - Create a itersine filterbanks + Itersine filterbank - This function create a itersine half overlap filterbank of Nf filters - Going from 0 to lambda_max + Create an itersine half overlap filterbank of Nf filters. + Going from 0 to lambda_max. Parameters ---------- - G : Graph + G : graph Nf : int (optional) Number of filters from 0 to lmax. (default = 6) overlap : int (optional) (default = 2) - Returns - ------- - out : Itersine - Examples -------- >>> from pygsp import graphs, filters @@ -36,7 +31,7 @@ def __init__(self, G, Nf=6, overlap=2., **kwargs): super(Itersine, self).__init__(G, **kwargs) def k(x): - return np.sin(0.5*pi*np.power(np.cos(x*pi), 2)) * ((x >= -0.5)*(x <= 0.5)) + return np.sin(0.5*np.pi*np.power(np.cos(x*np.pi), 2)) * ((x >= -0.5)*(x <= 0.5)) scale = G.lmax/(Nf - overlap + 1.)*overlap g = [] diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index c67a51d5..5d305618 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -1,20 +1,17 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import exp + +from . import Filter class MexicanHat(Filter): r""" - Mexican hat Filterbank - - Inherits its methods from Filters + Mexican hat filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters from 0 to lmax (default = 6) lpfactor : int @@ -28,10 +25,6 @@ class MexicanHat(Filter): Wether to normalize the wavelet by the factor/sqrt(t). (default = False) - Returns - ------- - out : MexicanHat - Examples -------- >>> from pygsp import graphs, filters @@ -55,7 +48,7 @@ def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, lminfac = .4 * G.lmin - g = [lambda x: 1.2 * exp(-1) * gl(x / lminfac)] + g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] for i in range(Nf - 1): if normalize: diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index b21a6be5..8e752b6a 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -1,27 +1,20 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Meyer(Filter): r""" - Meyer Filterbank - - Inherits its methods from Filters + Meyer filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters from 0 to lmax (default = 6) - Returns - ------- - out : Meyer - Examples -------- >>> from pygsp import graphs, filters @@ -80,10 +73,10 @@ def kernel_meyer(x, kerneltype): r = np.empty(x.shape) if kerneltype is 'sf': r[r1ind] = 1 - r[r2ind] = np.cos((pi/2) * v(np.abs(x[r2ind])/l1 - 1)) + r[r2ind] = np.cos((np.pi/2) * v(np.abs(x[r2ind])/l1 - 1)) elif kerneltype is 'wavelet': - r[r2ind] = np.sin((pi/2) * v(np.abs(x[r2ind])/l1 - 1)) - r[r3ind] = np.cos((pi/2) * v(np.abs(x[r3ind])/l2 - 1)) + r[r2ind] = np.sin((np.pi/2) * v(np.abs(x[r2ind])/l1 - 1)) + r[r3ind] = np.cos((np.pi/2) * v(np.abs(x[r3ind])/l2 - 1)) else: raise TypeError('Unknown kernel type ', kerneltype) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index da1fbc6f..faa1f8b6 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -1,34 +1,29 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Papadakis(Filter): r""" - Papadakis Filterbank - - Inherits its methods from Filters + Papadakis filterbank This function create a parseval filterbank of :math:`2`. - The low-pass filter is defined by a function :math:`f_l(x)` + The low-pass filter is defined by the function - .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ \sqrt{1-\frac{\sin\left(\frac{3\pi}{2a}x\right)}{2}} & \mbox{if }a\frac{5a}{3} \end{cases} + .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ + \sqrt{1-\frac{\sin\left(\frac{3\pi}{2a}x\right)}{2}} & \mbox{if }a\frac{5a}{3} \end{cases} - The high pass filter is adaptated to obtain a tight frame. + The high pass filter is adapted to obtain a tight frame. Parameters ---------- - G : Graph + G : graph a : float - See equation above for this parameter - The spectrum is scaled between 0 and 2 (default = 3/4) - - Returns - ------- - out : Papadakis + See above equation for this parameter. + The spectrum is scaled between 0 and 2 (default = 3/4). Examples -------- @@ -56,7 +51,7 @@ def papadakis(val, a): r3ind = val >= l2 y[r1ind] = 1 - y[r2ind] = np.sqrt((1 - np.sin(3*pi/(2*a) * val[r2ind]))/2.) + y[r2ind] = np.sqrt((1 - np.sin(3*np.pi/(2*a) * val[r2ind]))/2.) y[r3ind] = 0 return y diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 25a11231..f78e655d 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -1,16 +1,13 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Regular(Filter): r""" - Regular Filterbank - - Inherits its methods from Filters + Regular filterbank This function creates a parseval filterbank :math:`2` filters. The low-pass filter is defined by a function :math:`f_l(x)` @@ -26,20 +23,15 @@ class Regular(Filter): .. math:: f_{l}= \sin\left( \frac{\pi}{4} \left( 1+ \sin\left(\frac{\pi}{2} \sin\left(\frac{\pi}{2}(x-1)\right)\right) \right) \right) - And so for other degrees :math:`d` + And so forth for other degrees :math:`d`. - The high pass filter is adaptated to obtain a tight frame. + The high pass filter is adapted to obtain a tight frame. Parameters ---------- - G : Graph + G : graph d : float - See equations above for this parameter - Degree (default = 3) - - Returns - ------- - out : Regular + Degree (default = 3). See above equations. Examples -------- @@ -59,11 +51,11 @@ def __init__(self, G, d=3, **kwargs): def regular(val, d): if d == 0: - return np.sin(pi / 4.*val) + return np.sin(np.pi / 4.*val) else: - output = np.sin(pi*(val - 1) / 2.) + output = np.sin(np.pi*(val - 1) / 2.) for i in range(2, d): - output = np.sin(pi*output / 2.) + output = np.sin(np.pi*output / 2.) - return np.sin(pi / 4.*(1 + output)) + return np.sin(np.pi / 4.*(1 + output)) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 6188dac8..594ab158 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -1,34 +1,30 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class Simoncelli(Filter): r""" - Simoncelli Filterbank + Simoncelli filterbank - Inherits its methods from Filters This function create a parseval filterbank of :math:`2`. - The low-pass filter is defined by a function :math:`f_l(x)`. + The low-pass filter is defined by the function - .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ \cos\left(\frac{\pi}{2}\frac{\log\left(\frac{x}{2}\right)}{\log(2)}\right) & \mbox{if }a2a \end{cases} + .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ + \cos\left(\frac{\pi}{2}\frac{\log\left(\frac{x}{2}\right)}{\log(2)}\right) & \mbox{if }a2a \end{cases} - The high pass filter is is adaptated to obtain a tight frame. + The high pass filter is adapted to obtain a tight frame. Parameters ---------- - G : Graph + G : graph a : float - See equation above for this parameter - The spectrum is scaled between 0 and 2 (default = 2/3) - - Returns - ------- - out : Simoncelli + See above equation for this parameter. + The spectrum is scaled between 0 and 2 (default = 2/3). Examples -------- @@ -58,7 +54,7 @@ def simoncelli(val, a): r3ind = (val >= l2) y[r1ind] = 1 - y[r2ind] = np.cos(pi/2 * np.log(val[r2ind]/float(a)) / np.log(2)) + y[r2ind] = np.cos(np.pi/2 * np.log(val[r2ind]/float(a)) / np.log(2)) y[r3ind] = 0 return y diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletf.py index a71b5d06..d448cb0c 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletf.py @@ -1,30 +1,23 @@ # -*- coding: utf-8 -*- -from . import Filter - import numpy as np -from math import pi + +from . import Filter class SimpleTf(Filter): r""" - SimpleTf Filterbank - - Inherits its methods from Filters + SimpleTf filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters from 0 to lmax (default = 6) t : ndarray Vector of scale to be used (Initialized by default at the value of the log scale) - Returns - ------- - out : SimpleTf - Examples -------- >>> from pygsp import graphs, filters @@ -56,7 +49,7 @@ def kernel_simple_tf(x, kerneltype): l2 = 0.5 l3 = 1. - h = lambda x: np.sin(pi*x/2.)**2 + h = lambda x: np.sin(np.pi*x/2.)**2 r1ind = x < l1 r2ind = (x >= l1) * (x < l2) diff --git a/pygsp/filters/warpedtranslates.py b/pygsp/filters/warpedtranslates.py index c5f00502..34239e3e 100644 --- a/pygsp/filters/warpedtranslates.py +++ b/pygsp/filters/warpedtranslates.py @@ -5,26 +5,26 @@ class WarpedTranslates(Filter): r""" - Creates a vertex frequency filterbank + Vertex frequency filterbank Parameters ---------- - G : Graph + G : graph Nf : int Number of filters - Returns - ------- - out : WarpedTranslates + References + ---------- + See :cite:`shuman2013spectrum` Examples -------- - Not Implemented for now - # >>> from pygsp import graphs, filters - # >>> G = graphs.Logo() - # >>> F = filters.WarpedTranslates(G) - - See :cite:`shuman2013spectrum` + >>> from pygsp import graphs, filters + >>> G = graphs.Logo() + >>> F = filters.WarpedTranslates(G) + Traceback (most recent call last): + ... + NotImplementedError """ From 4f8abdcf1f24e3d904186ca4e4efc1d5e224fdc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 11:50:45 +0200 Subject: [PATCH 096/392] typo: spectrogramm --> spectrogram --- pygsp/features.py | 6 +++--- pygsp/graphs/graph.py | 8 ++++---- pygsp/plotting.py | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 1923ce44..47dd7958 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -72,7 +72,7 @@ def compute_norm_tig(filt, method=None, *args, **kwargs): return np.linalg.norm(tig, axis=1, ord=2) -def compute_spectrogramm(G, atom=None, M=100, method=None, **kwargs): +def compute_spectrogram(G, atom=None, M=100, method=None, **kwargs): r""" Compute the norm of the Tig for all nodes with a kernel shifted along the spectral axis. @@ -80,9 +80,9 @@ def compute_spectrogramm(G, atom=None, M=100, method=None, **kwargs): Parameters ---------- G : Graph object - The graph on which to compute the spectrogramm. + The graph on which to compute the spectrogram. atom : Filter kernel (optional) - Kernel to use in the spectrogramm (default = exp(-M*(x/lmax)²)). + Kernel to use in the spectrogram (default = exp(-M*(x/lmax)²)). M : int (optional) Number of samples on the spectral scale. (default = 100) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index ccd72579..d0891f74 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -750,14 +750,14 @@ def plot_signal(self, signal, **kwargs): from pygsp import plotting plotting.plot_signal(self, signal, show_plot=True, **kwargs) - def show_spectrogramm(self, **kwargs): + def show_spectrogram(self, **kwargs): r""" - Plot the spectrogramm for the graph object. + Plot the spectrogram for the graph object. - See plotting doc on spectrogramm. + See plotting doc on spectrogram. """ from pygsp import plotting - plotting.plot_spectrogramm(self, **kwargs) + plotting.plot_spectrogram(self, **kwargs) def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], iterations=50, scale=1.0, center=None): diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 0e947132..301d652a 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -762,35 +762,35 @@ def pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], window_list[str(uuid.uuid4())] = app -def plot_spectrogramm(G, **kwargs): +def plot_spectrogram(G, **kwargs): r""" - Plot the spectrogramm of the given graph. + Plot the spectrogram of the given graph. Parameters ---------- G : Graph object Graph to analyse. node_idx : ndarray - Order to sort the nodes in the spectrogramm + Order to sort the nodes in the spectrogram Example -------- >>> import numpy as np >>> from pygsp import graphs, plotting >>> G = graphs.Ring(15) - >>> plotting.plot_spectrogramm(G) + >>> plotting.plot_spectrogram(G) """ global window_list - from pygsp.features import compute_spectrogramm + from pygsp.features import compute_spectrogram if 'window_list' not in globals(): window_list = {} if not qtg_import: - raise NotImplementedError("You need pyqtgraph to plot the spectrogramm at the moment. Please install dependency and retry.") + raise NotImplementedError("You need pyqtgraph to plot the spectrogram at the moment. Please install dependency and retry.") if not hasattr(G, 'spectr'): - compute_spectrogramm(G) + compute_spectrogram(G) M = G.spectr.shape[1] node_idx = kwargs.pop('node_idx', None) From ab21a7b422344d63e8d6678619151da60f095c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 11:40:52 +0200 Subject: [PATCH 097/392] Graph: update docstring --- pygsp/graphs/graph.py | 104 +++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d0891f74..f3d1b24c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -12,60 +12,62 @@ class Graph(object): r""" - The main graph object. - - It is used to initialize by default every missing field of the subclass - graphs. It can also be used by itself to initialize customs graphs. - - - **Fields**: - - A graph contains the following fields: - - - N : the number of nodes (also called vertices sometimes) in the - graph. They represent the different points between which - connections may occur. - - Ne : the number of edges (also called links sometimes) in the graph. - They represent the actual connections between the nodes. - - W : the weight matrix 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. - - A : the adjacency matrix defines which edges exist on the graph. - It is represented as an N-by-N matrix of booleans. - :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. - - d : the degree vector of the vertices. - It is represented as a Nx1 vector counting the number of - connections that each node possesses. - - gtype : the graph type is a short description of the graph object. - It is a string designed to help sorting the graphs - - directed : the flag to assess if the graph is directed or not. - In this framework, we consider that a graph is directed if and - only if its weight matrix is non symmetric. - - L : the graph Laplacian matrix. - It is represented as an N-by-N matrix computed from W. - - lap_type : string that determines which kind of laplacian to compute. - From a given matrix W, there exist several Laplacians that could - be computed. - - coords : the coordinates of the vertices in the 2D or 3D space for - plotting. The default is None. - - plotting : all the plotting parameters go here. - They depend on the library used for plotting. + The base graph class. + * Provide a common interface to graph objects. + * Can be instantiated to construct custom graphs from a weight matrix. + * Initialize attributes for derived classes. Parameters ---------- - W : sparse matrix or ndarray (data is float) - Weight matrix. Mandatory. + W : sparse matrix or ndarray + weight matrix which encodes the graph gtype : string - Graph type (default is "unknown") - lap_type : string + graph type (default is 'unknown') + lap_type : 'none', 'normalized', 'combinatorial' Laplacian type (default is 'combinatorial') coords : ndarray - Coordinates of the vertices (default is None) + vertices coordinates (default is None) plotting : dict - Dictionnary containing the plotting parameters + 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 : ndarray + 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. + A : sparse matrix or ndarray + the adjacency matrix defines which edges exist on the graph. + It is represented as an N-by-N matrix of booleans. + :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. + d : ndarray + the degree vector is a vector of length N which represents the number + of edges connected to each node. + gtype : string + the graph type is a short description of the graph object designed to + help sorting the graphs. + directed : bool + indicates if the graph is directed or not. + In this framework, we consider that a graph is directed if and + only if its weight matrix is non symmetric. + L : sparse matrix or ndarray + the graph Laplacian, an N-by-N matrix computed from W. + lap_type : 'none', 'normalized', 'combinatorial' + determines which kind of Laplacian will be computed by + :func:`create_laplacian`. + coords : ndarray + vertices coordinates in 2D or 3D space. Used for plotting only. Default + is None. + plotting : dict + plotting parameters. Examples -------- @@ -519,7 +521,7 @@ def extract_components(self): r""" Split the graph into several connected components. - See the doc of `is_connected` for the method used to determine + See :func:`is_connected` for the method used to determine connectedness. Returns @@ -656,7 +658,7 @@ def create_laplacian(self, lap_type='combinatorial'): Parameters ---------- lap_type : string - The laplacian type to use. Default is "combinatorial". Other + The laplacian type to use. Default is 'combinatorial'. Other possible values are 'none' and 'normalized', which are not yet implemented for directed graphs. @@ -736,16 +738,16 @@ def plot(self, **kwargs): r""" Plot the graph. - See plotting doc. + See :func:`pygsp.plotting.plot_graph`. """ from pygsp import plotting plotting.plot_graph(self, show_plot=True, **kwargs) def plot_signal(self, signal, **kwargs): r""" - Plot the graph signal. + Plot a signal on that graph. - See plotting doc. + See :func:`pygsp.plotting.plot_signal`. """ from pygsp import plotting plotting.plot_signal(self, signal, show_plot=True, **kwargs) @@ -754,7 +756,7 @@ def show_spectrogram(self, **kwargs): r""" Plot the spectrogram for the graph object. - See plotting doc on spectrogram. + See :func:`pygsp.plotting.plot_spectrogram`. """ from pygsp import plotting plotting.plot_spectrogram(self, **kwargs) From a642d810084e5f94ae362d3ff1d299ac0aa4ad7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 12:17:08 +0200 Subject: [PATCH 098/392] Graph: code cleanup --- pygsp/graphs/graph.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index f3d1b24c..1295e5bf 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -83,11 +83,10 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.logger = build_logger(__name__, **kwargs) - shapes = np.shape(W) - if len(shapes) != 2 or shapes[0] != shapes[1]: - self.logger.error('W has incorrect shape {}'.format(shapes)) + if len(W.shape) != 2 or W.shape[0] != W.shape[1]: + self.logger.error('W has incorrect shape {}'.format(W.shape)) - self.N = shapes[0] + self.N = W.shape[0] self.W = sparse.lil_matrix(W) self.check_weights() @@ -118,12 +117,9 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', else: self.coords = np.ndarray(None) - # Plotting default parameters self.plotting = {'vertex_size': 10, 'edge_width': 1, 'edge_style': '-', 'vertex_color': 'b'} - - if isinstance(plotting, dict): - self.plotting.update(plotting) + self.plotting.update(plotting) def check_weights(self): r""" From 458ba5d2b02265c487a1b31f615679ebf2e5c574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 13:37:08 +0200 Subject: [PATCH 099/392] typo --- pygsp/graphs/ring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 1eb78551..ae5cbacd 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -29,7 +29,7 @@ def __init__(self, N=64, k=1, **kwargs): if 2*k > N: raise ValueError('Too many neighbors requested.') - # Create weighted adjancency matrix + # Create weighted adjacency matrix if 2*k == N: num_edges = N * (k - 1) + N / 2. else: From b2bbd9ead17e75c93a2692770bdfecdfadd67783 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 13:48:51 +0200 Subject: [PATCH 100/392] update plotting module * make implementation functions private * update docstrings * show_plot is default in implementation not in wrappers --- doc/tutorials/graph_tv.rst | 10 +- doc/tutorials/intro.rst | 8 +- doc/tutorials/wavelet.rst | 26 ++-- pygsp/filters/filter.py | 8 +- pygsp/graphs/graph.py | 4 +- pygsp/plotting.py | 214 +++++++++++++------------------ pygsp/pointclouds/pointclouds.py | 4 +- 7 files changed, 123 insertions(+), 151 deletions(-) diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst index d018470e..dbfadc4f 100644 --- a/doc/tutorials/graph_tv.rst +++ b/doc/tutorials/graph_tv.rst @@ -39,7 +39,7 @@ It is simply a projection on the B2-ball. Results and code ---------------- ->>> from pygsp import graphs, plotting +>>> from pygsp import graphs >>> import numpy as np >>> >>> # Create a random sensor graph @@ -49,7 +49,7 @@ Results and code >>> # Create signal >>> graph_value = np.copysign(np.ones(np.shape(G.U[:, 3])[0]), G.U[:, 3]) >>> ->>> plotting.plt_plot_signal(G, graph_value, savefig=True, plot_name='doc/tutorials/img/original_signal') +>>> G.plot_signal(graph_value, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/original_signal') .. figure:: img/original_signal.* @@ -63,7 +63,7 @@ This figure shows the original signal on graph. >>> sigma = 0.0 >>> depleted_graph_value = M * (graph_value.reshape(graph_value.size, 1) + sigma * np.random.standard_normal((G.N, 1))) >>> ->>> plotting.plt_plot_signal(G, depleted_graph_value, show_edges=True, savefig=True, plot_name='doc/tutorials/img/depleted_signal') +>>> G.plot_signal(depleted_graph_value, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/depleted_signal') .. figure:: img/depleted_signal.* @@ -90,7 +90,7 @@ mask and addition of noise. More than half of the vertices are set to 0. .. >>> ret = pyunlocbox.solvers.solve([f2, f1], **param) .. >>> prox_tv_reconstructed_graph = ret['sol'] .. >>> -.. >>> plotting.plt_plot_signal(G, prox_tv_reconstructed_graph, show_edges=True, savefig=True, plot_name='doc/tutorials/img/tv_recons_signal') +.. >>> G.plot_signal(prox_tv_reconstructed_graph, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/tv_recons_signal') .. figure:: img/tv_recons_signal.* @@ -110,7 +110,7 @@ The result is presented as following: .. >>> ret = pyunlocbox.solvers.solve([f3, f1], **param) .. >>> prox_tik_reconstructed_graph = ret['sol'] .. >>> -.. >>> plotting.plt_plot_signal(G, prox_tik_reconstructed_graph, show_edges=True, savefig=True, plot_name='doc/tutorials/img/tik_recons_signal') +.. >>> G.plot_signal(prox_tik_reconstructed_graph, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/tik_recons_signal') .. figure:: img/tik_recons_signal.* diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 65378739..d764e250 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -34,8 +34,8 @@ Looks good isn't it? Now we can start to analyse the graph. The next step to com You can now access the eigenvalues of the fourier basis with G.e and the eigenvectors G.U, they look like sinuses on the graph. Let's plot the second and third eigenvector, as the one is only constant. ->>> pygsp.plotting.plt_plot_signal(G, G.U[:, 1], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_second_eigenvector') ->>> pygsp.plotting.plt_plot_signal(G, G.U[:, 2], savefig=True, vertex_size=50, plot_name='doc/tutorials/img/logo_third_eigenvector') +>>> G.plot_signal(G.U[:, 1], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_second_eigenvector') +>>> G.plot_signal(G.U[:, 2], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_third_eigenvector') .. figure:: img/logo_second_eigenvector.* @@ -84,8 +84,8 @@ To apply it to a given signal, you only need to run: Finally here's the noisy signal and the denoised version right under. ->>> pygsp.plotting.plt_plot_signal(G, f, savefig=True, vertex_size=50, plot_name='doc/tutorials/img/noisy_logo') ->>> pygsp.plotting.plt_plot_signal(G, f2, savefig=True, vertex_size=50, plot_name='doc/tutorials/img/denoised_logo') +>>> G.plot_signal(f, vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/noisy_logo') +>>> G.plot_signal(f2, vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/denoised_logo') .. image:: img/noisy_logo.* .. image:: img/denoised_logo.* diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index e32cd316..6e938c89 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -40,10 +40,10 @@ Let's now create a signal as a Kronecker located on one vertex (e.g. the vertex Let's plot the signal: ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,0], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/heat_tau_1') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,1], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/heat_tau_10') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,2], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/heat_tau_25') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,3], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/heat_tau_50') +>>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_1') +>>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_10') +>>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_25') +>>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_50') .. figure:: img/heat_tau_1.* :alt: Tau = 1 @@ -92,7 +92,7 @@ If we want to get a better coverage of the graph spectrum, we could have used th >>> S_vec = Wk.analysis(S) >>> S = S_vec.reshape((S_vec.size//Nf, Nf), order='F') ->>> pygsp.plotting.plt_plot_signal(G, S[:, 0], savefig=True, plot_name='doc/tutorials/img/wavelet_filtering') +>>> G.plot_signal(S[:, 0], default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_filtering') We can visualize the filtering by one atom the same way the did for the Heat kernel, by placing a Kronecker delta at one specific vertex. @@ -103,10 +103,10 @@ We can visualize the filtering by one atom the same way the did for the Heat ker ... S[vertex_delta + i * G.N, i] = 1 >>> Sf = Wk.synthesis(S) ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,0], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/wavelet_1') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,1], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/wavelet_2') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,2], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/wavelet_3') ->>> pygsp.plotting.plt_plot_signal(G, Sf[:,3], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/wavelet_4') +>>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_1') +>>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_2') +>>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_3') +>>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_4') .. figure:: img/wavelet_1.* .. figure:: img/wavelet_2.* @@ -123,10 +123,10 @@ We can visualize the filtering by one atom the same way the did for the Heat ker >>> d = s_map_out[:, :, 0]**2 + s_map_out[:, :, 1]**2 + s_map_out[:, :, 2]**2 >>> d = np.sqrt(d) ->>> pygsp.plotting.plt_plot_signal(G, d[:, 1], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/curv_scale_1') ->>> pygsp.plotting.plt_plot_signal(G, d[:, 2], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/curv_scale_2') ->>> pygsp.plotting.plt_plot_signal(G, d[:, 3], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/curv_scale_3') ->>> pygsp.plotting.plt_plot_signal(G, d[:, 4], vertex_size=20, savefig=True, plot_name='doc/tutorials/img/curv_scale_4') +>>> G.plot_signal(d[:, 1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_1') +>>> G.plot_signal(d[:, 2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_2') +>>> G.plot_signal(d[:, 3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_3') +>>> G.plot_signal(d[:, 4], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_4') .. figure:: img/curv_scale_1.* .. figure:: img/curv_scale_2.* diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 325fb29a..ec4bec29 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -10,8 +10,8 @@ class Filter(object): r""" - Parent class for all Filters or Filterbanks, contains the shared methods - for those classes. + Base class for all filters or filterbanks. + Define the interface and implement shared methods. """ def __init__(self, G, filters=None, **kwargs): @@ -381,7 +381,7 @@ def plot(self, **kwargs): r""" Plot the filter. - See :ref:`plotting doc`. + See :func:`pygsp.plotting.plot_filter`. """ from pygsp import plotting - plotting.plot_filter(self, show_plot=True, **kwargs) \ No newline at end of file + plotting.plot_filter(self, **kwargs) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1295e5bf..e50d759d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -737,7 +737,7 @@ def plot(self, **kwargs): See :func:`pygsp.plotting.plot_graph`. """ from pygsp import plotting - plotting.plot_graph(self, show_plot=True, **kwargs) + plotting.plot_graph(self, **kwargs) def plot_signal(self, signal, **kwargs): r""" @@ -746,7 +746,7 @@ def plot_signal(self, signal, **kwargs): See :func:`pygsp.plotting.plot_signal`. """ from pygsp import plotting - plotting.plot_signal(self, signal, show_plot=True, **kwargs) + plotting.plot_signal(self, signal, **kwargs) def show_spectrogram(self, **kwargs): r""" diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 301d652a..3a42cc80 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -1,5 +1,16 @@ # -*- coding: utf-8 -*- -r"""This module implements plotting functions for the PyGSP main objects.""" + +r""" +The :mod:`pygsp.plotting` module implements functionality to plot the PyGSP +main objects with a `pyqtgraph `_ or `matplotlib +`_ drawing backend: + +* graphs from :mod:`pygsp.graphs` with :func:`plot_graph`, + :func:`plot_spectrogram`, and :func:`plot_signal`, +* filters from :mod:`pygsp.filters` with :func:`plot_filter`, +* point clouds from :mod:`pygsp.pointclouds` with :func:`plot_pointcloud`. + +""" import numpy as np import uuid @@ -34,47 +45,39 @@ def __init__(self): plid = plid() -def show(block=False): +def show(block=False, **kwargs): r""" Show created figures. - Equivalent to plt.show(*args, **kw) excepted you don't have to import - matplotlib by youself. - + Alias to plt.show(). By default, showing plots does not block the prompt. """ - plt.show(block) + plt.show(block, **kwargs) -def close(*args): +def close(*args, **kwargs): r""" Close created figures. - Strictly equivalent to plt.close(*args) excepted you don't have to import - matplotlib by youself. - - By default, showing plots does not block the prompt. + Alias to plt.close(). """ - plt.close(*args) + plt.close(*args, **kwargs) -def plot(O, default_qtg=True, **kwargs): +def plot(O, **kwargs): r""" Main plotting function. - This function should be able to determine the appropriate plot for - the object. - Additionnal kwargs may be given in case of filter plotting. + This convenience function either calls :func:`plot_graph`, + :func:`plot_pointcloud` or :func:`plot_filter` given the type of the passed + object. Parameters can be passed to those functions. Parameters ---------- - O : object - Should be either a Graph, Filter or PointCloud - default_qtg: boolean - Define the library to use if both are installed. - Default is pyqtgraph (field=True). + O : Graph, Filter, PointCloud + object to plot Examples -------- @@ -84,73 +87,64 @@ def plot(O, default_qtg=True, **kwargs): """ from .graphs import Graph - from .pointclouds.pointclouds import PointCloud + from .pointclouds import PointCloud from .filters import Filter if issubclass(type(O), Graph): - plot_graph(O, default_qtg, **kwargs) + plot_graph(O, **kwargs) elif issubclass(type(O), PointCloud): - plot_pointcloud(O) + plot_pointcloud(O, **kwargs) elif issubclass(type(O), Filter): plot_filter(O, **kwargs) else: - raise TypeError('Your object type is incorrect, be sure it is a ' + raise TypeError('Unrecognized object type, be sure it is a ' 'PointCloud, a Filter or a Graph.') def plot_graph(G, default_qtg=True, **kwargs): r""" - Plot a graph or an array of graphs with installed libraries. + Plot a graph or a list of graphs. This function should be able to determine the appropriate plot for the graph. - Additionnal kwargs may be given in case of filter plotting. Parameters ---------- G : Graph - Graph object to plot + Graph to plot. show_edges : boolean Set to False to only draw the vertices (default G.Ne < 10000). default_qtg: boolean - Define the library to use if both are installed. - Default is pyqtgraph (field=True). + define the drawing backend to use if both are available. + Default True, i.e. pyqtgraph. + plot_name : string + name of the plot + savefig : boolean + whether the plot is saved as plot_name.png and plot_name.pdf (True) or + shown in a window (False) (default False). Only available with the + matplotlib backend. + show_plot : boolean + whether to show the plot, i.e. call plt.show(). Only available with the + matplotlib backend. Examples -------- >>> from pygsp import graphs, plotting >>> G = graphs.Logo() - >>> try: - ... plotting.plot_graph(G, default_qtg=False) - ... except Exception as e: - ... print(e) + >>> plotting.plot_graph(G, default_qtg=False) """ if qtg_import and (default_qtg or not plt_import): - kwargs.pop('show_plot', None) - pg_plot_graph(G, **kwargs) + _pg_plot_graph(G, **kwargs) elif plt_import and not (default_qtg and qtg_import): - plt_plot_graph(G, **kwargs) + _plt_plot_graph(G, **kwargs) else: raise ImportError('No drawing library installed. Please ' 'install matplotlib or pyqtgraph.') -def plt_plot_graph(G, savefig=False, show_edges=None, show_plot=False, plot_name=''): - r""" - Plot a graph or an array of graphs with matplotlib. - - See plot_graph for full documentation. - - Extra args - ---------- - savefig : boolean - Determine wether the plot is saved as a PNG file in your\ - current directory (True) or shown in a window (False) (default False). - plot_name : str - To give custom names to plots +def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name=''): - """ # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph @@ -255,13 +249,8 @@ def plt_plot_graph(G, savefig=False, show_edges=None, show_plot=False, plot_name # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() -def pg_plot_graph(G, show_edges=None, plot_name=''): - r""" - Plot a graph or an array of graphs. - - See plot_graph for full documentation. +def _pg_plot_graph(G, show_edges=None, plot_name=''): - """ # TODO handling when G is a list of graphs global window_list if 'window_list' not in globals(): @@ -379,16 +368,14 @@ def plot_pointcloud(P): Parameters ---------- - P : PointCloud object + P : PointCloud + Point cloud to plot. Examples -------- >>> from pygsp import plotting, pointclouds >>> logo = pointclouds.PointCloud('logo') - >>> try: - ... plotting.plot_pointcloud(logo) - ... except: - ... pass + >>> plotting.plot_pointcloud(logo) """ if P.coords.shape[1] == 2: @@ -413,7 +400,8 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, Parameters ---------- - filters : filter object + filters : Filter + Filter to plot. npoints : int Number of point where the filters are evaluated. line_width : int @@ -423,27 +411,25 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x_size : int Size of the X marks representing the eigenvalues. plot_eigenvalues : boolean - To plot black X marks at all eigenvalues of the graph (You need to \ - compute the Fourier basis to use this option). By default the \ + To plot black X marks at all eigenvalues of the graph. You need to + compute the Fourier basis to use this option. By default the eigenvalues are plot if they are contained in the Graph. show_sum : boolean - To plot an extra line showing the sum of the squared magnitudes\ + To plot an extra line showing the sum of the squared magnitudes of the filters (default True if there is multiple filters). + plot_name : string + name of the plot savefig : boolean - Determine wether the plot is saved as a PNG file in your\ - current directory (True) or shown in a window (False) (default False). - plot_name : str - To give custom names to plots + whether the plot is saved as plot_name.png and plot_name.pdf (True) or + shown in a window (False) (default False). Only available with the + matplotlib backend. Examples -------- >>> from pygsp import filters, plotting, graphs >>> G = graphs.Logo() >>> mh = filters.MexicanHat(G) - >>> try: - ... plotting.plot_filter(mh) - ... except: - ... pass + >>> plotting.plot_filter(mh) """ G = filters.G @@ -495,26 +481,26 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, def plot_signal(G, signal, default_qtg=True, **kwargs): r""" - Plot a graph signal in 2D or 3D with installed libraries. + Plot a signal on top of a graph. Parameters ---------- - G : Graph object - If not specified it will take the one used to create the filter. + G : Graph + Graph to plot a signal on top. signal : array of int - Signal applied to the graph. + Signal to plot. Signal length should be equal to the number of nodes. show_edges : boolean Set to False to only draw the vertices (default G.Ne < 10000). - cp : List of int + cp : list of int Camera position for a 3D graph. vertex_size : int Size of circle representing each signal component. - vertex_highlight : boolean - Vector of indices of vertices to be highlighted. - climits : array of int + vertex_highlight : list of boolean + Vector of indices for vertices to be highlighted. + climits : list of int Limits of the colorbar. colorbar : boolean - To plot an extra line showing the sum of the squared magnitudes + To plot an extra line showing the sum of the squared magnitudes of the filters (default True if there is multiple filters). bar : boolean NOT IMPLEMENTED: False display color, True display bar for the graph @@ -522,48 +508,38 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): bar_width : int Width of the bar (default 1). default_qtg: boolean - Define the library to use if both are installed. - Default is pyqtgraph (field=True). + define the drawing backend to use if both are available. + Default True, i.e. pyqtgraph. + plot_name : string + name of the plot + savefig : boolean + whether the plot is saved as plot_name.png and plot_name.pdf (True) or + shown in a window (False) (default False). Only available with the + matplotlib backend. Examples -------- >>> import numpy as np >>> from pygsp import graphs, filters, plotting - >>> G = graphs.Ring(15) - >>> signal = np.sin((np.arange(1, 16)*2*np.pi/15)) - >>> try: - ... plotting.plot_signal(G, signal, default_qtg=False) - ... except: - ... pass + >>> G = graphs.Grid2d(4) + >>> signal = np.sin((np.arange(16) * 2*np.pi/16)) + >>> plotting.plot_signal(G, signal, default_qtg=False) """ if qtg_import and (default_qtg or not plt_import): - pg_plot_signal(G, signal, **kwargs) + _pg_plot_signal(G, signal, **kwargs) elif plt_import and not (default_qtg and qtg_import): - plt_plot_signal(G, signal, **kwargs) + _plt_plot_signal(G, signal, **kwargs) else: raise ImportError('No drawing library installed. Please ' 'install matplotlib or pyqtgraph.') -def plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], - vertex_size=None, vertex_highlight=False, climits=None, - colorbar=True, bar=False, bar_width=1, savefig=False, - show_plot=False, plot_name=None): - r""" - Plot a graph signal in 2D or 3D using matplotlib. - - See plot_signal for full documentation. +def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], + vertex_size=None, vertex_highlight=False, climits=None, + colorbar=True, bar=False, bar_width=1, savefig=False, + show_plot=False, plot_name=None): - Extra args - ---------- - savefig : boolean - Determine whether the plot is saved as a PNG file in your - current directory (True) or shown in a window (False) (default False). - plot_name : str - To give custom names to plots - - """ fig = plt.figure(plid.plot_id) plid.plot_id += 1 @@ -648,15 +624,10 @@ def plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], plt.show(False) # non blocking show -def pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], - vertex_size=None, vertex_highlight=False, climits=None, - colorbar=True, bar=False, bar_width=1, plot_name=None): - r""" - Plot a graph signal in 2D or 3D, with pyqtgraph. - - See plot_signal for full documentation. +def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], + vertex_size=None, vertex_highlight=False, climits=None, + colorbar=True, bar=False, bar_width=1, plot_name=None): - """ if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") @@ -762,18 +733,18 @@ def pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], window_list[str(uuid.uuid4())] = app -def plot_spectrogram(G, **kwargs): +def plot_spectrogram(G, node_idx=None): r""" Plot the spectrogram of the given graph. Parameters ---------- - G : Graph object + G : Graph Graph to analyse. node_idx : ndarray Order to sort the nodes in the spectrogram - Example + Examples -------- >>> import numpy as np >>> from pygsp import graphs, plotting @@ -793,7 +764,6 @@ def plot_spectrogram(G, **kwargs): compute_spectrogram(G) M = G.spectr.shape[1] - node_idx = kwargs.pop('node_idx', None) spectr = np.ravel(G.spectr[node_idx, :] if node_idx is not None else G.spectr) min_spec, max_spec = np.min(spectr), np.max(spectr) diff --git a/pygsp/pointclouds/pointclouds.py b/pygsp/pointclouds/pointclouds.py index cf629d0a..4c06f82a 100644 --- a/pygsp/pointclouds/pointclouds.py +++ b/pygsp/pointclouds/pointclouds.py @@ -101,7 +101,9 @@ def __init__(self, pointcloudname, max_dim=2): def plot(self, **kwargs): r""" - Plot the pointcloud. See :func:`pygsp.plotting.plot_pointcloud`. + Plot the pointcloud. + + See :func:`pygsp.plotting.plot_pointcloud`. """ from pygsp import plotting plotting.plot_pointcloud(self, **kwargs) From 55ceb01977eeb20ef3459b2ec88f9c3f8ed398c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 14:50:06 +0200 Subject: [PATCH 101/392] Filter: add doc --- pygsp/filters/filter.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index ec4bec29..b0bb2f66 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -158,6 +158,9 @@ def evaluate(self, x, *args, **kwargs): return fd def inverse(self, c, **kwargs): + r""" + Not implemented yet. + """ raise NotImplementedError def synthesis(self, c, order=30, method=None, **kwargs): @@ -168,7 +171,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): ---------- G : Graph structure. c : Transform coefficients - method : Select the method ot be used for the computation. + method : Select the method to be used for the computation. - 'exact' : Exact method using the graph Fourier matrix - 'cheby' : Chebyshev polynomial approximation - 'lanczos' : Lanczos approximation @@ -252,13 +255,13 @@ def synthesis(self, c, order=30, method=None, **kwargs): def approx(m, N, **kwargs): r""" - Not implemented yet + Not implemented yet. """ raise NotImplementedError def tighten(): r""" - Not implemented yet + Not implemented yet. """ raise NotImplementedError @@ -280,6 +283,16 @@ def filterbank_bounds(self, N=999, bounds=None): lower : Filterbank lower bound upper : Filterbank upper bound + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, filters + >>> G = graphs.Logo() + >>> MH = filters.MexicanHat(G) + >>> bounds = MH.filterbank_bounds() + >>> print('lower={:.3f}, upper={:.3f}'.format(bounds[0], bounds[1])) + lower=0.178, upper=0.270 + """ if bounds: xmin, xmax = bounds @@ -308,6 +321,14 @@ def filterbank_matrix(self): ------- F : Frame + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, filters + >>> G = graphs.Logo() + >>> MH = filters.MexicanHat(G) + >>> matrix = MH.filterbank_matrix() + """ N = self.G.N From b379bcd521d889ff21ead99251b6a185136bf693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 15:22:55 +0200 Subject: [PATCH 102/392] data_handling: update doc --- pygsp/data_handling.py | 51 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/pygsp/data_handling.py b/pygsp/data_handling.py index 7d777e62..8a3a1f88 100644 --- a/pygsp/data_handling.py +++ b/pygsp/data_handling.py @@ -1,9 +1,13 @@ # -*- coding: utf-8 -*- +r""" +The :mod:`pygsp.data_handling` module implements some functions to manipulate +data which might prove useful when using the toolbox. +""" + import numpy as np from scipy import sparse - def adj2vec(G): r""" Prepare the graph for the gradient computation. @@ -33,7 +37,7 @@ def adj2vec(G): def mat2vec(d): - r"""Not implemented yet""" + r"""Not implemented yet.""" raise NotImplementedError @@ -44,39 +48,34 @@ def repmatline(A, ncol=1, nrow=1): Parameters ---------- A : ndarray - ncol : Integer + ncol : int default is 1 - nrow : Integer + nrow : int default is 1 Returns ------- - Ar : Matrix + Ar : ndarray Examples -------- - - For nrow=2 and ncol=3, the matrix - :: - - x = [1 2 ] - [3 4 ] - - becomes - :: - - [1 1 1 2 2 2 ] - M = [1 1 1 2 2 2 ] - [3 3 3 4 4 4 ] - [3 3 3 4 4 4 ] - - with:: - M = np.repeat(np.repeat(x, nrow, axis=1), ncol, axis=0) + >>> from pygsp.data_handling import repmatline + >>> import numpy as np + >>> x = np.array([[1, 2], [3, 4]]) + >>> x + array([[1, 2], + [3, 4]]) + >>> repmatline(x, nrow=2, ncol=3) + array([[1, 1, 1, 2, 2, 2], + [1, 1, 1, 2, 2, 2], + [3, 3, 3, 4, 4, 4], + [3, 3, 3, 4, 4, 4]]) """ + if ncol < 1 or nrow < 1: - raise ValueError("The number of lines and rows must be greater or\ - equal to one, or you will get an empty array.") + raise ValueError('The number of lines and rows must be greater or ' + 'equal to one, or you will get an empty array.') return np.repeat(np.repeat(A, ncol, axis=1), nrow, axis=0) @@ -87,10 +86,10 @@ def vec2mat(d, Nf): Parameters ---------- - d : Ndarray + d : ndarray Data Nf : int - Number of filter + Number of filters Returns ------- From 2dc20b38cb0ad482657120629061b69e3de620fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 15:30:50 +0200 Subject: [PATCH 103/392] optimization: update doc --- pygsp/optimization.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index 4e74495b..b027398c 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- + r""" -This module provides optimization tools to accelarate graph signal processing as a whole. +The :mod:`pygsp.optimization` module provides tools for convex optimization on +graphs. """ from .data_handling import adj2vec @@ -16,10 +18,10 @@ def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix This function computes the TV proximal operator for graphs. The TV norm is the one norm of the gradient. The gradient is defined in the - function :func:`~pygsp.operator.grad`. - This function require the PyUNLocBoX to be executed. + function :func:`pygsp.operators.grad`. + This function requires the PyUNLocBoX to be executed. - pygsp.optimization.prox_tv(y, gamma, param) solves: + This function solves: :math:`sol = \min_{z} \frac{1}{2} \|x - z\|_2^2 + \gamma \|x\|_{TV}` @@ -42,7 +44,7 @@ def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix tol: float Stops criterion for the loop. The algorithm will stop if : :math:`\frac{n(t) - n(t - 1)} {n(t)} < tol` - where :math: `n(t) = f(x) + 0.5 \|x-y\|_2^2` is the objective function at iteration :math:`t` + where :math:`n(t) = f(x) + 0.5 \|x-y\|_2^2` is the objective function at iteration :math:`t` (default = :math:`10e-4`) maxit: int Maximum iteration. (default = 200) From 0b58d1382534e38a42ff0ed4351fef205f8364aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 15:38:30 +0200 Subject: [PATCH 104/392] features: update doc --- pygsp/features.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 47dd7958..86e4d95e 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- + r""" -This module implements different feature extraction techniques based on -Graphs and Filters of the GSP box. +The :mod:`pygsp.features` module implements different feature extraction +techniques based on :mod:`pygsp.graphs` and :mod:`pygsp.filters`. """ import numpy as np @@ -15,14 +16,14 @@ def compute_avg_adj_deg(G): r""" Compute the average adjacency degree for each node. - Average adjacency degree is the average of the degrees of a node and its - neighbors. + + The average adjacency degree is the average of the degrees of a node and + its neighbors. Parameters ---------- - G: Graph object - The graph on which the statistic is extracted - + G: Graph + Graph on which the statistic is extracted """ if not isinstance(G, Graph): raise ValueError("Graph object expected as first argument.") @@ -58,11 +59,11 @@ def compute_tig(filt, method=None, **kwargs): def compute_norm_tig(filt, method=None, *args, **kwargs): r""" Compute the :math:`\ell_2` norm of the Tig. - See `compute_tig`. + See :func:`compute_tig`. Parameters ---------- - filt: Filter object + filt: Filter The filter (or filterbank) method: string (optional) Which method to use. Accept 'cheby', 'exact' @@ -79,8 +80,8 @@ def compute_spectrogram(G, atom=None, M=100, method=None, **kwargs): Parameters ---------- - G : Graph object - The graph on which to compute the spectrogram. + G : Graph + Graph on which to compute the spectrogram. atom : Filter kernel (optional) Kernel to use in the spectrogram (default = exp(-M*(x/lmax)²)). M : int (optional) From 3e143ad679eb76b20021a0b9aa3006d0ee2350d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 15:57:18 +0200 Subject: [PATCH 105/392] utils: update doc --- pygsp/utils.py | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 7056bc26..6a9215d9 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- + r""" -This module implements some utilitary functions used throughout the PyGSP box. +The :mod:`pygsp.utils` module implements some utility functions used throughout +the package. """ import logging @@ -87,7 +89,7 @@ def inner(*args, **kwargs): def distanz(x, y=None): r""" - Calculate the distanz between two colon vectors + Calculate the distance between two colon vectors. Parameters ---------- @@ -104,10 +106,12 @@ def distanz(x, y=None): Examples -------- >>> import numpy as np - >>> from pygsp import utils - >>> x = np.random.rand(16) - >>> y = np.random.rand(16) - >>> distanz = utils.distanz(x, y) + >>> from pygsp.utils import distanz + >>> x = np.arange(3) + >>> distanz(x, x) + array([[ 0., 1., 2.], + [ 1., 0., 1.], + [ 2., 1., 0.]]) """ try: @@ -141,7 +145,7 @@ def distanz(x, y=None): return np.sqrt(d) -def resistance_distance(M): # 1 call dans operators.reduction +def resistance_distance(M): r""" Compute the resistance distances of a graph. @@ -155,17 +159,11 @@ def resistance_distance(M): # 1 call dans operators.reduction rd : sparse matrix distance matrix - Examples - -------- - >>> - >>> - >>> - References ---------- :cite:`klein1993resistance` - """ + if sparse.issparse(M): L = M.tocsc() @@ -192,7 +190,7 @@ def resistance_distance(M): # 1 call dans operators.reduction def symmetrize(W, symmetrize_type='average'): r""" - Symmetrize a square matrix + Symmetrize a square matrix. Parameters ---------- @@ -202,6 +200,21 @@ def symmetrize(W, symmetrize_type='average'): 'average' : symmetrize by averaging with the transpose. 'full' : symmetrize by filling in the holes in the transpose. + Examples + -------- + >>> import numpy as np + >>> from pygsp.utils import symmetrize + >>> x = np.array([[1,0],[3,4.]]) + >>> x + array([[ 1., 0.], + [ 3., 4.]]) + >>> symmetrize(x) + array([[ 1. , 1.5], + [ 1.5, 4. ]]) + >>> symmetrize(x, symmetrize_type='full') + array([[ 1., 3.], + [ 3., 4.]]) + """ if W.shape[0] != W.shape[1]: raise ValueError("Matrix must be square") @@ -220,7 +233,7 @@ def symmetrize(W, symmetrize_type='average'): W += mask.multiply(W.T) if sparse_flag else (mask * W.T) return (W + W.T) / 2. # Resolve ambiguous entries else: - raise ValueError("Unknown symmetrize type.") + raise ValueError("Unknown symmetrization type.") def rescale_center(x): From e081449ef513d16b27abd10d678c05872efe1ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 17:38:12 +0200 Subject: [PATCH 106/392] unused import --- pygsp/tests/test_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index eb3d4777..7fcf9c1c 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -7,7 +7,6 @@ import unittest import numpy as np -import numpy.testing as nptest from scipy import sparse from pygsp import utils, graphs, operators From 14d954f87012d0e300eaeada39ec3935b908fa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 18:10:41 +0200 Subject: [PATCH 107/392] operators: private functions --- pygsp/operators/reduction.py | 8 ++++---- pygsp/operators/vertex_frequency_analysis.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index bc9c342c..ab91638a 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -529,7 +529,7 @@ def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): ca.append(s_pred + pe[levels - i - 1]) else: - ca.append(pyramid_single_interpolation(Gs[levels - i - 1], ca[i], + ca.append(_pyramid_single_interpolation(Gs[levels - i - 1], ca[i], pe[levels - i - 1], h_filters[levels - i - 1], use_landweber=use_landweber, **kwargs)) @@ -539,7 +539,7 @@ def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): return reconstruction, ca -def pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): +def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): r""" Sythesize a single level of the graph pyramid transform. @@ -622,7 +622,7 @@ def pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): finer_approx = spsolve(Ta.T * Ta, Ta.T * np.concatenate((ca, pe), axis=0)) -def tree_depths(A, root): +def _tree_depths(A, root): r"""Empty docstring. TODO.""" if not Graph(A=A).is_connected(): raise ValueError('Graph is not connected') @@ -691,7 +691,7 @@ def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', Gs[0].compute_fourier_basis() subsampled_vertex_indices = [] - depths, parents = tree_depths(G.A, root) + depths, parents = _tree_depths(G.A, root) old_W = G.W for lev in range(Nlevel): diff --git a/pygsp/operators/vertex_frequency_analysis.py b/pygsp/operators/vertex_frequency_analysis.py index 3503337d..9fcb6120 100644 --- a/pygsp/operators/vertex_frequency_analysis.py +++ b/pygsp/operators/vertex_frequency_analysis.py @@ -42,7 +42,7 @@ def generalized_wft(G, g, f, lowmemory=True): if not lowmemory: # Compute the Frame into a big matrix - Frame = gwft_frame_matrix(G, g) + Frame = _gwft_frame_matrix(G, g) C = np.dot(Frame.T, f) C = np.reshape(C, (G.N, G.N, Nf), order='F') @@ -90,7 +90,7 @@ def gabor_wft(G, f, k): return C -def gwft_frame_matrix(G, g): +def _gwft_frame_matrix(G, g): r""" Create the matrix of the GWFT frame @@ -142,7 +142,7 @@ def ngwft(G, f, g, lowmemory=True): if lowmemory: # Compute the Frame into a big matrix - Frame = ngwft_frame_matrix(G, g) + Frame = _ngwft_frame_matrix(G, g) C = np.dot(Frame.T, f) C = np.reshape(C, (G.N, G.N), order='F') @@ -163,7 +163,7 @@ def ngwft(G, f, g, lowmemory=True): return C -def ngwft_frame_matrix(G, g): +def _ngwft_frame_matrix(G, g): r""" Create the matrix of the GWFT frame From f2abb5ac278681d3ab52e01a07d2cb85f68d5c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 18:11:18 +0200 Subject: [PATCH 108/392] operators: update doc --- pygsp/operators/operator.py | 4 ++-- pygsp/operators/reduction.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pygsp/operators/operator.py b/pygsp/operators/operator.py index 567d71ab..c929398b 100644 --- a/pygsp/operators/operator.py +++ b/pygsp/operators/operator.py @@ -222,7 +222,7 @@ def localize(g, i): def modulate(G, f, k): r""" - Tranlate the signal f to the node i. + Modulation the signal f to the frequency k. Parameters ---------- @@ -247,7 +247,7 @@ def modulate(G, f, k): def translate(G, f, i): r""" - Tranlate the signal f to the node i + Translate the signal f to the node i. Parameters ---------- diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index ab91638a..2b53bd48 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -r"""This module contains functionalities for the reduction of graphs' vertex set while keeping the graph structure.""" from ..utils import resistance_distance, build_logger from ..graphs import Graph @@ -541,7 +540,7 @@ def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): r""" - Sythesize a single level of the graph pyramid transform. + Synthesize a single level of the graph pyramid transform. Parameters ---------- From ccc8f71b9ce275881d3724058c00bf5f2c3ac77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 16 Aug 2017 08:30:20 +0200 Subject: [PATCH 109/392] doc: simplify reference guide generation --- doc/reference/data_handling.rst | 20 +-- doc/reference/fast_filtering.rst | 23 --- doc/reference/features.rst | 3 +- doc/reference/filters.rst | 127 +--------------- doc/reference/graphs.rst | 158 +------------------- doc/reference/index.rst | 92 +----------- doc/reference/nngraphs.rst | 43 ------ doc/reference/operators.rst | 43 +----- doc/reference/optimization.rst | 6 +- doc/reference/plotting.rst | 30 +--- doc/reference/pointclouds.rst | 16 +- doc/reference/reduction.rst | 44 ------ doc/reference/utils.rst | 40 +---- doc/reference/vertex_frequency_analysis.rst | 24 --- doc/tutorials/intro.rst | 6 +- 15 files changed, 44 insertions(+), 631 deletions(-) delete mode 100644 doc/reference/fast_filtering.rst delete mode 100644 doc/reference/nngraphs.rst delete mode 100644 doc/reference/reduction.rst delete mode 100644 doc/reference/vertex_frequency_analysis.rst diff --git a/doc/reference/data_handling.rst b/doc/reference/data_handling.rst index 986d45bb..a177f6f1 100644 --- a/doc/reference/data_handling.rst +++ b/doc/reference/data_handling.rst @@ -1,21 +1,7 @@ -.. _data_handling-api: - ============= Data Handling ============= -Adj2vec -------- -.. autofunction:: pygsp.data_handling.adj2vec - -Mat2vec -------- -.. autofunction:: pygsp.data_handling.mat2vec - -Repmatline ----------- -.. autofunction:: pygsp.data_handling.repmatline - -Vec2mat -------- -.. autofunction:: pygsp.data_handling.vec2mat +.. automodule:: pygsp.data_handling + :members: + :undoc-members: diff --git a/doc/reference/fast_filtering.rst b/doc/reference/fast_filtering.rst deleted file mode 100644 index dae87f2e..00000000 --- a/doc/reference/fast_filtering.rst +++ /dev/null @@ -1,23 +0,0 @@ -============== -Fast Filtering -============== - -Compute Chebyshev Coefficient ------------------------------ -.. autofunction:: pygsp.operators.fast_filtering.compute_cheby_coeff - -Chebyshev Operator ------------------- -.. autofunction:: pygsp.operators.fast_filtering.cheby_op - -Chebyshev Rectangle Filter --------------------------- -.. autofunction:: pygsp.operators.fast_filtering.cheby_rect - -Compute Jackson-Chebyshev Coefficient -------------------------------------- -.. autofunction:: pygsp.operators.fast_filtering.compute_jackson_cheby_coeff - -Lanczos Operator ----------------- -.. autofunction:: pygsp.operators.fast_filtering.lanczos_op diff --git a/doc/reference/features.rst b/doc/reference/features.rst index 51def591..f93de255 100644 --- a/doc/reference/features.rst +++ b/doc/reference/features.rst @@ -3,4 +3,5 @@ Features ======== .. automodule:: pygsp.features - :members: + :members: + :undoc-members: diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index d71ab286..d2b7c389 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -1,126 +1,7 @@ -.. _filters-api: +======= +Filters +======= -=============== -Filters Objects -=============== - -Main Filters Class ------------------- - -.. autoclass:: pygsp.filters.Filter - :undoc-members: - :show-inheritance: - :members: - - -Abspline --------- - -.. autoclass:: pygsp.filters.Abspline - :undoc-members: - :show-inheritance: - :members: - -Expwin ------- - -.. autoclass:: pygsp.filters.Expwin - :undoc-members: - :show-inheritance: - :members: - -Gabor ------ - -.. autoclass:: pygsp.filters.Gabor - :undoc-members: - :show-inheritance: - :members: - -HalfCosine ----------- - -.. autoclass:: pygsp.filters.HalfCosine - :undoc-members: - :show-inheritance: +.. automodule:: pygsp.filters :members: - -Heat ----- - -.. autoclass:: pygsp.filters.Heat - :undoc-members: - :show-inheritance: - :members: - -Held ----- - -.. autoclass:: pygsp.filters.Held - :undoc-members: - :show-inheritance: - :members: - -Itersine --------- - -.. autoclass:: pygsp.filters.Itersine :undoc-members: - :show-inheritance: - :members: - -MexicanHat ----------- - -.. autoclass:: pygsp.filters.MexicanHat - :undoc-members: - :show-inheritance: - :members: - -Meyer ------ - -.. autoclass:: pygsp.filters.Meyer - :undoc-members: - :show-inheritance: - :members: - -Papadakis ---------- - -.. autoclass:: pygsp.filters.Papadakis - :undoc-members: - :show-inheritance: - :members: - -Regular -------- - -.. autoclass:: pygsp.filters.Regular - :undoc-members: - :show-inheritance: - :members: - -Simoncelli ----------- - -.. autoclass:: pygsp.filters.Simoncelli - :undoc-members: - :show-inheritance: - :members: - -SimpleTf --------- - -.. autoclass:: pygsp.filters.SimpleTf - :undoc-members: - :show-inheritance: - :members: - -WarpedTranslates ----------------- - -.. autoclass:: pygsp.filters.WarpedTranslates - :undoc-members: - :show-inheritance: - :members: diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index 83749e45..b9da7102 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -1,157 +1,7 @@ -.. _graphs-api: +====== +Graphs +====== -============== -Graphs objects -============== - -Main Graph Class ----------------- - -.. autoclass:: pygsp.graphs.Graph - :undoc-members: - :show-inheritance: - :members: - -Grid2d ------- - -.. autoclass:: pygsp.graphs.Grid2d - :undoc-members: - :show-inheritance: - :members: - -Torus ------ - -.. autoclass:: pygsp.graphs.Torus - :undoc-members: - :show-inheritance: - :members: - -Comet ------ - -.. autoclass:: pygsp.graphs.Comet - :undoc-members: - :show-inheritance: - :members: - -LowStretchTree --------------- - -.. autoclass:: pygsp.graphs.LowStretchTree - :undoc-members: - :show-inheritance: - :members: - -RandomRegular -------------- - -.. autoclass:: pygsp.graphs.RandomRegular - :undoc-members: - :show-inheritance: - :members: - -Ring ----- - -.. autoclass:: pygsp.graphs.Ring - :undoc-members: - :show-inheritance: - :members: - -Community ---------- - -.. autoclass:: pygsp.graphs.Community - :undoc-members: - :show-inheritance: - :members: - -Minnesota ---------- - -.. autoclass:: pygsp.graphs.Minnesota - :undoc-members: - :show-inheritance: - :members: - -Sensor ------- - -.. autoclass:: pygsp.graphs.Sensor - :undoc-members: - :show-inheritance: - :members: - -Airfoil -------- - -.. autoclass:: pygsp.graphs.Airfoil - :undoc-members: - :show-inheritance: +.. automodule:: pygsp.graphs :members: - -DavidSensorNet --------------- - -.. autoclass:: pygsp.graphs.DavidSensorNet :undoc-members: - :show-inheritance: - :members: - -FullConnected -------------- - -.. autoclass:: pygsp.graphs.FullConnected - :undoc-members: - :show-inheritance: - :members: - -Logo ----- - -.. autoclass:: pygsp.graphs.Logo - :undoc-members: - :show-inheritance: - :members: - -Path ----- - -.. autoclass:: pygsp.graphs.Path - :undoc-members: - :show-inheritance: - :members: - -RandomRing ----------- - -.. autoclass:: pygsp.graphs.RandomRing - :undoc-members: - :show-inheritance: - :members: - -SwissRoll ---------- - -.. autoclass:: pygsp.graphs.SwissRoll - :undoc-members: - :show-inheritance: - :members: - -Barabasi-Albert ---------------- - -.. autoclass:: pygsp.graphs.BarabasiAlbert - :undoc-members: - :show-inheritance: - :members: - -Stochastic Block Model ----------------------- - -.. autoclass:: pygsp.graphs.StochasticBlockModel - :undoc-members: - :show-inheritance: - :members: diff --git a/doc/reference/index.rst b/doc/reference/index.rst index 6a2b763e..f686cae3 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -1,106 +1,18 @@ -.. _reference-guide: - =============== Reference guide =============== -Toolbox overview ----------------- - .. automodule:: pygsp -Graphs ------- - -.. automodule:: pygsp.graphs - .. toctree:: - :maxdepth: 2 :hidden: graphs - nngraphs - -Filters -------- - -.. automodule:: pygsp.filters - -.. toctree:: - :maxdepth: 2 - :hidden: - filters - -Operators ---------- - -.. automodule:: pygsp.operators - -.. toctree:: - :maxdepth: 2 - operators - reduction - fast_filtering - vertex_frequency_analysis - -PointCloud ----------- - -.. automodule:: pygsp.pointclouds - -.. toctree:: - :maxdepth: 2 - pointclouds - -Plotting --------- - -.. automodule:: pygsp.plotting - -.. toctree:: - :maxdepth: 2 - plotting - -Optimization ------------- - -.. automodule:: pygsp.optimization - -.. toctree:: - :maxdepth: 2 - - optimization - -Data Handling -------------- - -.. automodule:: pygsp.data_handling - -.. toctree:: - :maxdepth: 2 - - data_handling - -Features --------- - -.. automodule:: pygsp.features - -.. toctree:: - :maxdepth: 2 - features - -Utils ------- - -.. automodule:: pygsp.utils - -.. toctree:: - :maxdepth: 2 - + data_handling + optimization utils diff --git a/doc/reference/nngraphs.rst b/doc/reference/nngraphs.rst deleted file mode 100644 index 81082d86..00000000 --- a/doc/reference/nngraphs.rst +++ /dev/null @@ -1,43 +0,0 @@ -================ -NNGraphs objects -================ - -NNGraphs Class --------------- -.. autoclass:: pygsp.graphs.NNGraph - :undoc-members: - :show-inheritance: - :members: - -Bunny ------ - -.. autoclass:: pygsp.graphs.Bunny - :undoc-members: - :show-inheritance: - :members: - - -Cube ----- - -.. autoclass:: pygsp.graphs.Cube - :undoc-members: - :show-inheritance: - :members: - -Sphere ------- - -.. autoclass:: pygsp.graphs.Sphere - :undoc-members: - :show-inheritance: - :members: - -TwoMoons --------- - -.. autoclass:: pygsp.graphs.TwoMoons - :undoc-members: - :show-inheritance: - :members: diff --git a/doc/reference/operators.rst b/doc/reference/operators.rst index ad796a1a..df7dce44 100644 --- a/doc/reference/operators.rst +++ b/doc/reference/operators.rst @@ -1,38 +1,7 @@ -.. _operators-api: - -=================== -Operators functions -=================== - -Divergence ----------- -.. autofunction:: pygsp.operators.div - -Gradient --------- -.. autofunction:: pygsp.operators.grad - -Gradient Matriciel ------------------- -.. autofunction:: pygsp.operators.grad_mat - -Graph Fourier Transform ------------------------ -.. autofunction:: pygsp.operators.gft - -Inverse Graph Fourier Transform -------------------------------- -.. autofunction:: pygsp.operators.igft - -Localize --------- -.. autofunction:: pygsp.operators.localize - -Modulate --------- -.. autofunction:: pygsp.operators.modulate - -Translate ---------- -.. autofunction:: pygsp.operators.translate +========= +Operators +========= +.. automodule:: pygsp.operators + :members: + :undoc-members: diff --git a/doc/reference/optimization.rst b/doc/reference/optimization.rst index 0d477565..2fada89c 100644 --- a/doc/reference/optimization.rst +++ b/doc/reference/optimization.rst @@ -2,6 +2,6 @@ Optimization ============ -Prox TV -------- -.. autofunction:: pygsp.optimization.prox_tv +.. automodule:: pygsp.optimization + :members: + :undoc-members: diff --git a/doc/reference/plotting.rst b/doc/reference/plotting.rst index 83493ed4..442ed6af 100644 --- a/doc/reference/plotting.rst +++ b/doc/reference/plotting.rst @@ -1,25 +1,7 @@ -.. _plotting-api: +======== +Plotting +======== -================== -Plotting functions -================== - -Plot ----- -.. autofunction:: pygsp.plotting.plot - -Plot Graph ----------- -.. autofunction:: pygsp.plotting.plot_graph - -Plot Pointcloud ---------------- -.. autofunction:: pygsp.plotting.plot_pointcloud - -Plot Filter ------------ -.. autofunction:: pygsp.plotting.plot_filter - -Plot Signal ------------ -.. autofunction:: pygsp.plotting.plot_signal +.. automodule:: pygsp.plotting + :members: + :undoc-members: diff --git a/doc/reference/pointclouds.rst b/doc/reference/pointclouds.rst index 5d223382..f587c111 100644 --- a/doc/reference/pointclouds.rst +++ b/doc/reference/pointclouds.rst @@ -1,13 +1,7 @@ -.. _pointclouds-api: +============ +Point Clouds +============ -=========== -PointClouds -=========== - -PointCloud Class ----------------- - -.. autoclass:: pygsp.pointclouds.PointCloud - :undoc-members: - :show-inheritance: +.. automodule:: pygsp.pointclouds :members: + :undoc-members: diff --git a/doc/reference/reduction.rst b/doc/reference/reduction.rst deleted file mode 100644 index 3511aaf9..00000000 --- a/doc/reference/reduction.rst +++ /dev/null @@ -1,44 +0,0 @@ -=================== -Reduction functions -=================== - -Graph Sparsify --------------- -.. autofunction:: pygsp.operators.reduction.graph_sparsify - -Interpolate ------------ -.. autofunction:: pygsp.operators.reduction.interpolate - -Graph Multiresolution ---------------------- -.. autofunction:: pygsp.operators.reduction.graph_multiresolution - -Kron Reduction --------------- -.. autofunction:: pygsp.operators.reduction.kron_reduction - -Pyramid Analysis ----------------- -.. autofunction:: pygsp.operators.reduction.pyramid_analysis - -Pyramid Cell2coeff ------------------- -.. autofunction:: pygsp.operators.reduction.pyramid_cell2coeff - -Pyramid Synthesis ------------------ -.. autofunction:: pygsp.operators.reduction.pyramid_synthesis - -Pyramid Single Interpolation ----------------------------- -.. autofunction:: pygsp.operators.reduction.pyramid_single_interpolation - -Tree Depths ------------ -.. autofunction:: pygsp.operators.reduction.tree_depths - -Tree Multiresolution --------------------- -.. autofunction:: pygsp.operators.reduction.tree_multiresolution - diff --git a/doc/reference/utils.rst b/doc/reference/utils.rst index 94e711fc..5343b61d 100644 --- a/doc/reference/utils.rst +++ b/doc/reference/utils.rst @@ -1,35 +1,7 @@ -===== -Utils -===== +========= +Utilities +========= -Build logger ------------- -.. autofunction:: pygsp.utils.build_logger - -Graph array handler -------------------- -.. autofunction:: pygsp.utils.graph_array_handler - -Filterbank handler ------------------- -.. autofunction:: pygsp.utils.filterbank_handler - -Sparsifier ----------- -.. autofunction:: pygsp.utils.sparsifier - -Distanz -------- -.. autofunction:: pygsp.utils.distanz - -Resistance distance -------------------- -.. autofunction:: pygsp.utils.resistance_distance - -Symmetrize ----------- -.. autofunction:: pygsp.utils.symmetrize - -Rescale and center ------------------- -.. autofunction:: pygsp.utils.rescale_center +.. automodule:: pygsp.utils + :members: + :undoc-members: diff --git a/doc/reference/vertex_frequency_analysis.rst b/doc/reference/vertex_frequency_analysis.rst deleted file mode 100644 index 03ad3b19..00000000 --- a/doc/reference/vertex_frequency_analysis.rst +++ /dev/null @@ -1,24 +0,0 @@ -========================= -Vertex Frequency Analysis -========================= - -Gwft ----- -.. autofunction:: pygsp.operators.vertex_frequency_analysis.generalized_wft - -Gwft2 ------ -.. autofunction:: pygsp.operators.vertex_frequency_analysis.gabor_wft - -Gwft Frame Matrix ------------------ -.. autofunction:: pygsp.operators.vertex_frequency_analysis.gwft_frame_matrix - -Ngwft ------ -.. autofunction:: pygsp.operators.vertex_frequency_analysis.ngwft - -Ngwft Frame Matrix ------------------- -.. autofunction:: pygsp.operators.vertex_frequency_analysis.ngwft_frame_matrix - diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index d764e250..6f358c27 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -14,7 +14,7 @@ The first step is to create a graph, there's a general class that can be used to >>> W = np.random.rand(400, 400) >>> G = pygsp.graphs.Graph(W) -You have now a graph structure ready to be used everywhere in the box! Check the :ref:`reference-guide` to know more about the Graph class and it's subclasses. +You have now a graph structure ready to be used everywhere in the box! Check the :mod:`pygsp.graphs` module to know more about the Graph class and it's subclasses. You can also check the included methods for all graphs with the usual help function. For the next steps of the demo, we will be using the logo graph bundled with the toolbox : @@ -32,7 +32,7 @@ Looks good isn't it? Now we can start to analyse the graph. The next step to com >>> G.compute_fourier_basis() You can now access the eigenvalues of the fourier basis with G.e and the eigenvectors G.U, they look like sinuses on the graph. -Let's plot the second and third eigenvector, as the one is only constant. +Let's plot the second and third eigenvectors, as the first is constant. >>> G.plot_signal(G.U[:, 1], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_second_eigenvector') >>> G.plot_signal(G.U[:, 2], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_third_eigenvector') @@ -90,6 +90,6 @@ Finally here's the noisy signal and the denoised version right under. .. image:: img/noisy_logo.* .. image:: img/denoised_logo.* -So here are the basics for the PyGSP toolbox, please check the other tutorials or the `reference guide ` for more. +So here are the basics for the PyGSP toolbox, please check the other tutorials or the reference guide for more. Enjoy the toolbox! From 3ee1e3c9cec67be0e5577f2451a86f78f744aded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 15 Aug 2017 18:41:13 +0200 Subject: [PATCH 110/392] operators/fast_filtering.py -> filters/approximations.py --- pygsp/filters/__init__.py | 2 ++ .../approximations.py} | 0 pygsp/filters/filter.py | 22 ++++++++++--------- pygsp/operators/__init__.py | 1 - 4 files changed, 14 insertions(+), 11 deletions(-) rename pygsp/{operators/fast_filtering.py => filters/approximations.py} (100%) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index a089ec83..51940547 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -18,3 +18,5 @@ # Automaticaly import all classes from subfiles defined in __all__ for class_to_import in __all__: setattr(sys.modules[__name__], class_to_import, getattr(importlib.import_module('.' + class_to_import.lower(), 'pygsp.filters'), class_to_import)) + +from .approximations import * diff --git a/pygsp/operators/fast_filtering.py b/pygsp/filters/approximations.py similarity index 100% rename from pygsp/operators/fast_filtering.py rename to pygsp/filters/approximations.py diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index b0bb2f66..c6c755cd 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- -from .. import utils -from ..operators import fast_filtering, operator - -import numpy as np from math import log from copy import deepcopy +import numpy as np + +from .. import utils +from ..operators import operator +from . import approximations + class Filter(object): r""" @@ -76,12 +78,12 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, self.logger.info('FILTER_ANALYSIS: computing lmax.') self.G.estimate_lmax() - cheb_coef = fast_filtering.compute_cheby_coeff(self, m=cheb_order) - c = fast_filtering.cheby_op(self.G, cheb_coef, s) + cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) + c = approximations.cheby_op(self.G, cheb_coef, s) elif method == 'lanczos': # Lanczos approx raise NotImplementedError - # c = fast_filtering.lanczos_op(self, s, order=lanczos_order) + # c = approximations.lanczos_op(self, s, order=lanczos_order) elif method == 'exact': # Exact computation if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): @@ -229,13 +231,13 @@ def synthesis(self, c, order=30, method=None, **kwargs): 'The function will compute it for you.') self.G.estimate_lmax() - cheb_coeffs = fast_filtering.compute_cheby_coeff( + cheb_coeffs = approximations.compute_cheby_coeff( self, m=order, N=order + 1) s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) for i in range(Nf): - s = s + fast_filtering.cheby_op(self.G, + s = s + approximations.cheby_op(self.G, cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': @@ -243,7 +245,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): tmpN = np.arange(N, dtype=int) for i in range(Nf): - s += fast_filtering.lanczos_op(self.G, self.g[i], + s += approximations.lanczos_op(self.G, self.g[i], c[i * N + tmpN], order=order) diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index ec558f44..851f0bfc 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- r"""This module implements the main operators for the PyGSP box.""" -from .fast_filtering import * from .operator import * from .reduction import * from .vertex_frequency_analysis import * From a10c942e5530d42cd967a5b37b6e730b3c533d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 16 Aug 2017 10:54:16 +0200 Subject: [PATCH 111/392] reorganize operators package * difference.py: differential operators * transforms.py: frequency and vertex-frequency transforms * localization.py: localize, modulate, translate * reduction.py: multiresolution, pyramid, kron reduction --- pygsp/filters/filter.py | 29 ++-- pygsp/operators/__init__.py | 6 +- .../operators/{operator.py => difference.py} | 148 +----------------- pygsp/operators/localization.py | 86 ++++++++++ pygsp/operators/reduction.py | 9 +- ...ex_frequency_analysis.py => transforms.py} | 79 +++++++++- pygsp/optimization.py | 6 +- 7 files changed, 185 insertions(+), 178 deletions(-) rename pygsp/operators/{operator.py => difference.py} (52%) create mode 100644 pygsp/operators/localization.py rename pygsp/operators/{vertex_frequency_analysis.py => transforms.py} (72%) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index c6c755cd..17434737 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -6,7 +6,7 @@ import numpy as np from .. import utils -from ..operators import operator +from ..operators.transforms import gft, igft from . import approximations @@ -105,21 +105,19 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, if Nf == 1: if is2d: - return operator.igft(self.G, np.tile(fie, (Ns, 1)).T * - operator.gft(self.G, s)) + fs = np.tile(fie, (Ns, 1)).T * gft(self.G, s) + return igft(self.G, fs) else: - return operator.igft(self.G, fie * operator.gft(self.G, s)) + return igft(self.G, fie * gft(self.G, s)) else: tmpN = np.arange(N, dtype=int) for i in range(Nf): if is2d: - c[tmpN + N * i] = \ - operator.igft(self.G, np.tile(fie[i], (Ns, 1)).T * - operator.gft(self.G, s)) + fs = np.tile(fie[i], (Ns, 1)).T * gft(self.G, s) + c[tmpN + N * i] = igft(self.G, fs) else: - c[tmpN + N * i] = \ - operator.igft(self.G, fie[i] * - operator.gft(self.G, s)) + fs = fie[i] * gft(self.G, s) + c[tmpN + N * i] = igft(self.G, fs) else: raise ValueError('Unknown method: please select exact, ' @@ -216,14 +214,13 @@ def synthesis(self, c, order=30, method=None, **kwargs): tmpN = np.arange(N, dtype=int) if Nf == 1: - s += operator.igft(np.conjugate(self.G.U), - np.tile(fie, (Nv, 1)).T * - operator.gft(self.G, c[tmpN])) + fc = np.tile(fie, (Nv, 1)).T * gft(self.G, c[tmpN]) + s += igft(np.conjugate(self.G.U), fc) else: for i in range(Nf): - s += operator.igft(np.conjugate(self.G.U), - np.tile(fie[:][i], (Nv, 1)).T * - operator.gft(self.G, c[N * i + tmpN])) + fc = gft(self.G, c[N * i + tmpN]) + fc *= np.tile(fie[:][i], (Nv, 1)).T + s += igft(np.conjugate(self.G.U), fc) elif method == 'cheby': if hasattr(self.G, 'lmax'): diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index 851f0bfc..1efef7ce 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -r"""This module implements the main operators for the PyGSP box.""" -from .operator import * +from .difference import * +from .transforms import * +from .localization import * from .reduction import * -from .vertex_frequency_analysis import * diff --git a/pygsp/operators/operator.py b/pygsp/operators/difference.py similarity index 52% rename from pygsp/operators/operator.py rename to pygsp/operators/difference.py index c929398b..f7d5ce20 100644 --- a/pygsp/operators/operator.py +++ b/pygsp/operators/difference.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -from ..utils import build_logger -from ..data_handling import adj2vec - import numpy as np from scipy import sparse +from ..utils import build_logger +from ..data_handling import adj2vec + logger = build_logger(__name__) @@ -74,7 +74,7 @@ def grad(G, s): return gr -def grad_mat(G): # 1 call (above) +def grad_mat(G): r""" Gradient sparse matrix of the graph G. @@ -129,143 +129,3 @@ def grad_mat(G): # 1 call (above) G.Diff = D return D - - -def gft(G, f): - r""" - Compute Graph Fourier transform. - - Parameters - ---------- - G : Graph or Fourier basis - f : ndarray - must be in 2d, even if the second dim is 1 signal - - Returns - ------- - f_hat : ndarray - Graph Fourier transform of *f* - """ - - from pygsp.graphs import Graph - - if isinstance(G, Graph): - if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues ' + - 'and the eigenvectors.') - G.compute_fourier_basis() - - U = G.U - else: - U = G - - return np.dot(np.conjugate(U.T), f) # True Hermitian here. - - -def igft(G, f_hat): - r""" - Compute inverse graph Fourier transform. - - Parameters - ---------- - G : Graph or Fourier basis - f_hat : ndarray - Signal - - Returns - ------- - f : ndarray - Inverse graph Fourier transform of *f_hat* - - """ - - from pygsp.graphs import Graph - - if isinstance(G, Graph): - if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues ' + - 'and the eigenvectors.') - G.compute_fourier_basis() - U = G.U - - else: - U = G - - return np.dot(U, f_hat) - - -def localize(g, i): - r""" - Localize a kernel g to the node i. - - Parameters - ---------- - g : Filter - kernel (or filterbank) - i : int - Index of vertex - - Returns - ------- - gt : ndarray - Translated signal - - """ - N = g.G.N - f = np.zeros((N)) - f[i - 1] = 1 - - gt = np.sqrt(N) * g.analysis(f) - - return gt - - -def modulate(G, f, k): - r""" - Modulation the signal f to the frequency k. - - Parameters - ---------- - G : Graph - f : ndarray - Signal (column) - k : int - Index of frequencies - - Returns - ------- - fm : ndarray - Modulated signal - - """ - nt = np.shape(f)[1] - fm = np.sqrt(G.N) * np.kron(np.ones((nt, 1)), f) * \ - np.kron(np.ones((1, nt)), G.U[:, k]) - - return fm - - -def translate(G, f, i): - r""" - Translate the signal f to the node i. - - Parameters - ---------- - G : Graph - f : ndarray - Signal - i : int - Indices of vertex - - Returns - ------- - ft : translate signal - - """ - - fhat = gft(G, f) - nt = np.shape(f)[1] - - ft = np.sqrt(G.N) * igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) - - return ft diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py new file mode 100644 index 00000000..8c66ffc9 --- /dev/null +++ b/pygsp/operators/localization.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- + +import numpy as np + +from ..utils import build_logger +from .transforms import gft, igft + + +logger = build_logger(__name__) + + +def localize(g, i): + r""" + Localize a kernel g to the node i. + + Parameters + ---------- + g : Filter + kernel (or filterbank) + i : int + Index of vertex + + Returns + ------- + gt : ndarray + Translated signal + + """ + N = g.G.N + f = np.zeros((N)) + f[i - 1] = 1 + + gt = np.sqrt(N) * g.analysis(f) + + return gt + + +def modulate(G, f, k): + r""" + Modulation the signal f to the frequency k. + + Parameters + ---------- + G : Graph + f : ndarray + Signal (column) + k : int + Index of frequencies + + Returns + ------- + fm : ndarray + Modulated signal + + """ + nt = np.shape(f)[1] + fm = np.sqrt(G.N) * np.kron(np.ones((nt, 1)), f) * \ + np.kron(np.ones((1, nt)), G.U[:, k]) + + return fm + + +def translate(G, f, i): + r""" + Translate the signal f to the node i. + + Parameters + ---------- + G : Graph + f : ndarray + Signal + i : int + Indices of vertex + + Returns + ------- + ft : translate signal + + """ + + fhat = gft(G, f) + nt = np.shape(f)[1] + + ft = np.sqrt(G.N) * igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) + + return ft diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 2b53bd48..bd5258ab 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- -from ..utils import resistance_distance, build_logger -from ..graphs import Graph -from ..filters import Filter - import numpy as np from scipy import sparse, stats from scipy.sparse.linalg import eigs, spsolve +from ..utils import build_logger, resistance_distance +from ..graphs import Graph +from ..filters import Filter + + logger = build_logger(__name__) diff --git a/pygsp/operators/vertex_frequency_analysis.py b/pygsp/operators/transforms.py similarity index 72% rename from pygsp/operators/vertex_frequency_analysis.py rename to pygsp/operators/transforms.py index 9fcb6120..15153a9b 100644 --- a/pygsp/operators/vertex_frequency_analysis.py +++ b/pygsp/operators/transforms.py @@ -1,14 +1,77 @@ # -*- coding: utf-8 -*- -from .. import data_handling -from ..operators import operator +import numpy as np + from ..utils import build_logger +from ..data_handling import vec2mat, repmatline -import numpy as np logger = build_logger(__name__) +def gft(G, f): + r""" + Compute Graph Fourier transform. + + Parameters + ---------- + G : Graph or Fourier basis + f : ndarray + must be in 2d, even if the second dim is 1 signal + + Returns + ------- + f_hat : ndarray + Graph Fourier transform of *f* + """ + + from pygsp.graphs import Graph + + if isinstance(G, Graph): + if not hasattr(G, 'U'): + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') + G.compute_fourier_basis() + + U = G.U + else: + U = G + + return np.dot(np.conjugate(U.T), f) # True Hermitian here. + + +def igft(G, f_hat): + r""" + Compute inverse graph Fourier transform. + + Parameters + ---------- + G : Graph or Fourier basis + f_hat : ndarray + Signal + + Returns + ------- + f : ndarray + Inverse graph Fourier transform of *f_hat* + + """ + + from pygsp.graphs import Graph + + if isinstance(G, Graph): + if not hasattr(G, 'U'): + logger.info('Analysis filter has to compute the eigenvalues ' + + 'and the eigenvectors.') + G.compute_fourier_basis() + U = G.U + + else: + U = G + + return np.dot(U, f_hat) + + def generalized_wft(G, g, f, lowmemory=True): r""" Graph windowed Fourier transform @@ -36,9 +99,9 @@ def generalized_wft(G, g, f, lowmemory=True): G.compute_fourier_basis() if isinstance(g, list): - g = operator.igft(G, g[0](G.e)) + g = igft(G, g[0](G.e)) elif hasattr(g, '__call__'): - g = operator.igft(G, g(G.e)) + g = igft(G, g(G.e)) if not lowmemory: # Compute the Frame into a big matrix @@ -85,7 +148,7 @@ def gabor_wft(G, f, k): g = Gabor(G, k) C = g.analysis(f) - C = data_handling.vec2mat(C, G.N).T + C = vec2mat(C, G.N).T return C @@ -110,7 +173,7 @@ def _gwft_frame_matrix(G, g): ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(G.N)*np.dot(G.U, (np.kron(np.ones((1, G.N)), ghat)*G.U.T)) - F = data_handling.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) + F = repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) return F @@ -183,7 +246,7 @@ def _ngwft_frame_matrix(G, g): ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(g.N)*np.dot(G.U, (np.kron(np.ones((G.N)), ghat)*G.U.T)) - F = data_handling.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) + F = repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) # Normalization F /= np.kron((np.ones((G.N)), np.sqrt(np.sum(np.power(np.abs(F), 2), diff --git a/pygsp/optimization.py b/pygsp/optimization.py index b027398c..5fa382d9 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -6,7 +6,7 @@ """ from .data_handling import adj2vec -from .operators import operator +from .operators.difference import grad, div from .utils import build_logger logger = build_logger(__name__) @@ -81,9 +81,9 @@ def l1_at(x): return G.Diff * At(D.T * x) else: def l1_a(x): - return operator.grad(G, A(x)) + return grad(G, A(x)) def l1_at(x): - return operator.div(G, x) + return div(G, x) pyunlocbox.prox_l1(x, gamma, A=l1_a, At=l1_at, tight=tight, maxit=maxit, verbose=verbose, tol=tol) From 62edb932fd14b3039ac996de4e89055003af06a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 16 Aug 2017 16:13:48 +0200 Subject: [PATCH 112/392] remove PointCloud object and integrate with Graph --- doc/reference/index.rst | 1 - doc/reference/pointclouds.rst | 7 -- pygsp/__init__.py | 2 - .../misc => data/pointclouds}/airfoil.mat | Bin .../misc => data/pointclouds}/bunny.mat | Bin .../misc => data/pointclouds}/david500.mat | Bin .../misc => data/pointclouds}/david64.mat | Bin .../misc => data/pointclouds}/logogsp.mat | Bin .../misc => data/pointclouds}/minnesota.mat | Bin .../misc => data/pointclouds}/two_moons.mat | Bin pygsp/graphs/airfoil.py | 29 +++-- pygsp/graphs/davidsensornet.py | 28 +++-- pygsp/graphs/logo.py | 18 +-- pygsp/graphs/minnesota.py | 23 ++-- pygsp/graphs/nngraphs/bunny.py | 24 ++-- pygsp/graphs/nngraphs/nngraph.py | 8 +- pygsp/graphs/nngraphs/twomoons.py | 44 ++++--- pygsp/plotting.py | 52 ++------- pygsp/pointclouds/__init__.py | 7 -- pygsp/pointclouds/pointclouds.py | 109 ------------------ pygsp/utils.py | 31 +++++ setup.py | 8 +- 22 files changed, 138 insertions(+), 253 deletions(-) delete mode 100644 doc/reference/pointclouds.rst rename pygsp/{pointclouds/misc => data/pointclouds}/airfoil.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/bunny.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/david500.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/david64.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/logogsp.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/minnesota.mat (100%) rename pygsp/{pointclouds/misc => data/pointclouds}/two_moons.mat (100%) delete mode 100644 pygsp/pointclouds/__init__.py delete mode 100644 pygsp/pointclouds/pointclouds.py diff --git a/doc/reference/index.rst b/doc/reference/index.rst index f686cae3..0505f0a8 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -10,7 +10,6 @@ Reference guide graphs filters operators - pointclouds plotting features data_handling diff --git a/doc/reference/pointclouds.rst b/doc/reference/pointclouds.rst deleted file mode 100644 index f587c111..00000000 --- a/doc/reference/pointclouds.rst +++ /dev/null @@ -1,7 +0,0 @@ -============ -Point Clouds -============ - -.. automodule:: pygsp.pointclouds - :members: - :undoc-members: diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 4d1d37d4..686af80c 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -26,7 +26,6 @@ from pygsp import features from pygsp import operators -from pygsp import pointclouds # Silence the code checker warning about unused symbols. @@ -35,7 +34,6 @@ assert graphs assert operators assert optimization -assert pointclouds assert features assert plotting assert utils diff --git a/pygsp/pointclouds/misc/airfoil.mat b/pygsp/data/pointclouds/airfoil.mat similarity index 100% rename from pygsp/pointclouds/misc/airfoil.mat rename to pygsp/data/pointclouds/airfoil.mat diff --git a/pygsp/pointclouds/misc/bunny.mat b/pygsp/data/pointclouds/bunny.mat similarity index 100% rename from pygsp/pointclouds/misc/bunny.mat rename to pygsp/data/pointclouds/bunny.mat diff --git a/pygsp/pointclouds/misc/david500.mat b/pygsp/data/pointclouds/david500.mat similarity index 100% rename from pygsp/pointclouds/misc/david500.mat rename to pygsp/data/pointclouds/david500.mat diff --git a/pygsp/pointclouds/misc/david64.mat b/pygsp/data/pointclouds/david64.mat similarity index 100% rename from pygsp/pointclouds/misc/david64.mat rename to pygsp/data/pointclouds/david64.mat diff --git a/pygsp/pointclouds/misc/logogsp.mat b/pygsp/data/pointclouds/logogsp.mat similarity index 100% rename from pygsp/pointclouds/misc/logogsp.mat rename to pygsp/data/pointclouds/logogsp.mat diff --git a/pygsp/pointclouds/misc/minnesota.mat b/pygsp/data/pointclouds/minnesota.mat similarity index 100% rename from pygsp/pointclouds/misc/minnesota.mat rename to pygsp/data/pointclouds/minnesota.mat diff --git a/pygsp/pointclouds/misc/two_moons.mat b/pygsp/data/pointclouds/two_moons.mat similarity index 100% rename from pygsp/pointclouds/misc/two_moons.mat rename to pygsp/data/pointclouds/two_moons.mat diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 72b2e633..135cac71 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from . import Graph -from ..pointclouds import PointCloud - import numpy as np -from scipy import sparse +from scipy.sparse import coo_matrix + +from . import Graph +from ..utils import loadmat class Airfoil(Graph): @@ -20,20 +20,17 @@ class Airfoil(Graph): def __init__(self, **kwargs): - airfoil = PointCloud("airfoil") - i_inds = airfoil.i_inds - j_inds = airfoil.j_inds + data = loadmat('pointclouds/airfoil') + coords = np.concatenate((data['x'], data['y']), axis=1) - A = sparse.coo_matrix((np.ones((12289)), - (np.reshape(i_inds - 1, (12289)), - np.reshape(j_inds - 1, (12289)))), - shape=(4253, 4253)) + i_inds = np.reshape(data['i_inds'] - 1, 12289) + j_inds = np.reshape(data['j_inds'] - 1, 12289) + A = coo_matrix((np.ones(12289), (i_inds, j_inds)), shape=(4253, 4253)) W = (A + A.T) / 2. plotting = {"vertex_size": 30, - "limits": np.array([-1e-4, 1.01*np.max(airfoil.x), - -1e-4, 1.01*np.max(airfoil.y)])} + "limits": np.array([-1e-4, 1.01*data['x'].max(), + -1e-4, 1.01*data['y'].max()])} - super(Airfoil, self).__init__(W=W, coords=airfoil.coords, - plotting=plotting, gtype='Airfoil', - **kwargs) + super(Airfoil, self).__init__(W=W, coords=coords, plotting=plotting, + gtype='Airfoil', **kwargs) diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 23c27298..2ddcfef8 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph -from ..pointclouds import PointCloud -from ..utils import distanz - import numpy as np -from scipy import sparse + +from . import Graph +from ..utils import loadmat, distanz class DavidSensorNet(Graph): @@ -15,25 +13,31 @@ class DavidSensorNet(Graph): Parameters ---------- N : int - Number of vertices (default = 64) + Number of vertices (default = 64). Values of 64 and 500 yield + pre-computed and saved graphs. Other values yield randomly generated + graphs. Examples -------- >>> from pygsp import graphs + >>> G = graphs.DavidSensorNet(N=64) >>> G = graphs.DavidSensorNet(N=500) + >>> G = graphs.DavidSensorNet(N=123) """ def __init__(self, N=64): if N == 64: - david64 = PointCloud("david64") - W = david64.W - coords = david64.coords + data = loadmat('pointclouds/david64') + assert data['N'][0, 0] == N + W = data['W'] + coords = data['coords'] elif N == 500: - david500 = PointCloud("david500") - W = david500.W - coords = david500.coords + data = loadmat('pointclouds/david500') + assert data['N'][0, 0] == N + W = data['W'] + coords = data['coords'] else: coords = np.random.rand(N, 2) diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index b955618b..f598a85d 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from . import Graph -from ..pointclouds import PointCloud - import numpy as np +from . import Graph +from ..utils import loadmat + class Logo(Graph): r""" @@ -18,11 +18,15 @@ class Logo(Graph): """ def __init__(self, **kwargs): - logo = PointCloud("logo") - self.info = logo.info + data = loadmat('pointclouds/logogsp') + + self.info = {"idx_g": data["idx_g"], + "idx_s": data["idx_s"], + "idx_p": data["idx_p"]} plotting = {"limits": np.array([0, 640, -400, 0])} - super(Logo, self).__init__(W=logo.W, coords=logo.coords, gtype='LogoGSP', - plotting=plotting, **kwargs) + super(Logo, self).__init__(W=data['W'], coords=data['coords'], + gtype='LogoGSP', plotting=plotting, + **kwargs) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 0cb22ce5..082b55bf 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from ..pointclouds import PointCloud -from . import Graph - import numpy as np +from . import Graph +from ..utils import loadmat + class Minnesota(Graph): r""" @@ -15,26 +15,29 @@ class Minnesota(Graph): connect : bool Change the graph to be connected. (default = True) + References + ---------- + See :cite:`gleich` + Examples -------- >>> from pygsp import graphs >>> G = graphs.Minnesota() - References - ---------- - See :cite:`gleich` - """ def __init__(self, connect=True): - minnesota = PointCloud('minnesota') + + data = loadmat('pointclouds/minnesota') + self.labels = data['labels'] + A = data['A'] plotting = {"limits": np.array([-98, -89, 43, 50]), "vertex_size": 30} if connect: # Edit adjacency matrix - A = (minnesota.A > 0).astype(int) + A = (A > 0).astype(int) # clean minnesota graph A.setdiag(0) @@ -58,5 +61,5 @@ def __init__(self, connect=True): else: gtype = 'minnesota-disconnected' - super(Minnesota, self).__init__(W=A, coords=minnesota.coords, + super(Minnesota, self).__init__(W=A, coords=data['xy'], gtype=gtype, plotting=plotting) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 43cb4121..fa1bee38 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -1,28 +1,32 @@ # -*- coding: utf-8 -*- from . import NNGraph -from ...pointclouds import PointCloud +from ...utils import loadmat class Bunny(NNGraph): r""" - Create a graph of the stanford bunny. + Create a graph of the Stanford bunny. + + References + ---------- + See :cite:`turk1994zippered`. Examples -------- >>> from pygsp import graphs >>> G = graphs.Bunny() - References - ---------- - See :cite:`turk1994zippered` - """ def __init__(self, **kwargs): - bunny = PointCloud("bunny") - plotting = {"vertex_size": 10, 'vertex_color': (1, 1, 1, 1), 'edge_color': (.5, .5, .5, 1)} + data = loadmat('pointclouds/bunny') + + plotting = {'vertex_size': 10, + 'vertex_color': (1, 1, 1, 1), + 'edge_color': (.5, .5, .5, 1)} - super(Bunny, self).__init__(Xin=bunny.Xin, epsilon=0.2, NNtype="radius", - plotting=plotting, gtype="Bunny", **kwargs) + super(Bunny, self).__init__(Xin=data['bunny'], epsilon=0.2, + NNtype='radius', plotting=plotting, + gtype='Bunny', **kwargs) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index c4287ecb..2a047ee7 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -16,7 +16,7 @@ class NNGraph(Graph): r""" - Creates a graph from a pointcloud. + Create a nearest-neighbor graph from a point cloud. Parameters ---------- @@ -71,11 +71,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, plotting={}, symmetrize_type='average', dist_type='euclidean', order=0, **kwargs): - if Xin is None: - raise ValueError('You must enter a Xin to process the NNgraph') - else: - self.Xin = Xin - + self.Xin = Xin self.NNtype = NNtype self.use_flann = use_flann self.center = center diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 29f29f6f..7bbabdf4 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from . import NNGraph -from ...pointclouds import PointCloud - import numpy as np +from . import NNGraph +from ...utils import loadmat + class TwoMoons(NNGraph): r""" @@ -12,26 +12,36 @@ class TwoMoons(NNGraph): Parameters ---------- - moontype : string + moontype : 'standard' or 'synthesized' You have the freedom to chose if you want to create a standard 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 Variance of the distance kernel (default = 0.05) + dim : int + The dimensionality of the points (default = 2). + Only valid for moontype == 'standard'. N : int Number of vertices (default = 2000) + Only valid for moontype == 'synthesized'. sigmad : 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'. d : float Distance of the two moons (default = 0.5) + Only valid for moontype == 'synthesized'. Examples -------- >>> from pygsp import graphs - >>> G1 = graphs.TwoMoons(moontype='standard') - >>> G2 = graphs.TwoMoons(moontype='synthesized', N=1000, sigmad=0.1, d=1) + >>> G = graphs.TwoMoons(moontype='standard', dim=4) + >>> G.coords.shape + (2000, 4) + >>> G = graphs.TwoMoons(moontype='synthesized', N=1000, sigmad=0.1, d=1) + >>> G.coords.shape + (1000, 2) """ @@ -53,15 +63,14 @@ def _create_arc_moon(self, N, sigmad, d, number): return np.concatenate((moonx, moony), axis=1) - def __init__(self, moontype='standard', sigmag=0.05, N=400, sigmad=0.07, d=0.5): + def __init__(self, moontype='standard', dim=2, sigmag=0.05, + N=400, sigmad=0.07, d=0.5): if moontype == 'standard': - two_moons = PointCloud('two_moons') - Xin = two_moons.Xin - gtype = 'Two Moons standard' - self.labels = 2*(np.where(np.arange(1, N + 1).reshape(N, 1) > 1000, - 1, 0) + 1) + N1, N2 = 1000, 1000 + data = loadmat('pointclouds/two_moons') + Xin = data['features'][:dim].T elif moontype == 'synthesized': gtype = 'Two Moons synthesized' @@ -69,14 +78,11 @@ def __init__(self, moontype='standard', sigmag=0.05, N=400, sigmad=0.07, d=0.5): N1 = N // 2 N2 = N - N1 - # Moon 1 - Coordmoon1 = self._create_arc_moon(N1, sigmad, d, 1) + coords1 = self._create_arc_moon(N1, sigmad, d, 1) + coords2 = self._create_arc_moon(N2, sigmad, d, 2) - # Moon 2 - Coordmoon2 = self._create_arc_moon(N2, sigmad, d, 2) + Xin = np.concatenate((coords1, coords2)) - Xin = np.concatenate((Coordmoon1, Coordmoon2)) - self.labels = 2*(np.where(np.arange(1, N + 1).reshape(N, 1) > - N1, 1, 0) + 1) + self.labels = np.concatenate((np.zeros(N1), np.ones(N2))) super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, gtype=gtype) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 3a42cc80..12f54977 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- r""" -The :mod:`pygsp.plotting` module implements functionality to plot the PyGSP -main objects with a `pyqtgraph `_ or `matplotlib +The :mod:`pygsp.plotting` module implements functionality to plot PyGSP objects +with a `pyqtgraph `_ or `matplotlib `_ drawing backend: * graphs from :mod:`pygsp.graphs` with :func:`plot_graph`, :func:`plot_spectrogram`, and :func:`plot_signal`, -* filters from :mod:`pygsp.filters` with :func:`plot_filter`, -* point clouds from :mod:`pygsp.pointclouds` with :func:`plot_pointcloud`. +* filters from :mod:`pygsp.filters` with :func:`plot_filter`. """ @@ -70,13 +69,13 @@ def plot(O, **kwargs): r""" Main plotting function. - This convenience function either calls :func:`plot_graph`, - :func:`plot_pointcloud` or :func:`plot_filter` given the type of the passed - object. Parameters can be passed to those functions. + This convenience function either calls :func:`plot_graph` or + :func:`plot_filter` given the type of the passed object. Parameters can be + passed to those functions. Parameters ---------- - O : Graph, Filter, PointCloud + O : Graph, Filter object to plot Examples @@ -87,18 +86,14 @@ def plot(O, **kwargs): """ from .graphs import Graph - from .pointclouds import PointCloud from .filters import Filter if issubclass(type(O), Graph): plot_graph(O, **kwargs) - elif issubclass(type(O), PointCloud): - plot_pointcloud(O, **kwargs) elif issubclass(type(O), Filter): plot_filter(O, **kwargs) else: - raise TypeError('Unrecognized object type, be sure it is a ' - 'PointCloud, a Filter or a Graph.') + raise TypeError('Unrecognized object type, i.e. not Graph or Filter.') def plot_graph(G, default_qtg=True, **kwargs): @@ -230,6 +225,7 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name marker='o', markersize=G.plotting['vertex_size'], markerfacecolor=G.plotting['vertex_color']) else: + # TODO: is ax.plot(G.coords[:, 0], G.coords[:, 1], 'bo') faster? if G.coords.shape[1] == 2: ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', s=G.plotting['vertex_size'], @@ -362,36 +358,6 @@ def _pg_plot_graph(G, show_edges=None, plot_name=''): window_list[str(uuid.uuid4())] = app -def plot_pointcloud(P): - r""" - Plot the coordinates of a pointcloud. - - Parameters - ---------- - P : PointCloud - Point cloud to plot. - - Examples - -------- - >>> from pygsp import plotting, pointclouds - >>> logo = pointclouds.PointCloud('logo') - >>> plotting.plot_pointcloud(logo) - - """ - if P.coords.shape[1] == 2: - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 - ax = fig.add_subplot(111) - ax.plot(P.coords[:, 0], P.coords[:, 1], 'bo') - # plt.show() - if P.coords.shape[1] == 3: - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 - ax = fig.add_subplot(111, projection='3d') - ax.plot(P.coords[:, 0], P.coords[:, 1], P.coords[:, 2], 'bo') - # plt.show() - - def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x_size=10, plot_eigenvalues=None, show_sum=None, savefig=False, show_plot=False, plot_name=None): diff --git a/pygsp/pointclouds/__init__.py b/pygsp/pointclouds/__init__.py deleted file mode 100644 index 44b34035..00000000 --- a/pygsp/pointclouds/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- - -r"""This module implements some PointClouds.""" - -__all__ = ['PointCloud'] - -from .pointclouds import * diff --git a/pygsp/pointclouds/pointclouds.py b/pygsp/pointclouds/pointclouds.py deleted file mode 100644 index 4c06f82a..00000000 --- a/pygsp/pointclouds/pointclouds.py +++ /dev/null @@ -1,109 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy as np -from scipy import io - -from os import path - - -class PointCloud(object): - r""" - Load model parameters and points. - - Parameters - ---------- - name : string - The name of the point cloud to load. - Possible arguments: 'airfoil', 'bunny', 'david64', 'david500', 'logo', - 'minnesota', two_moons'. - max_dim : int - The maximum dimensionality of the points (only valid for two_moons) - (default is 2) - - Returns - ------- - A PointCloud object with data and parameters. - - Notes - ----- - The bunny is the model from the Stanford Computer Graphics Laboratory - (see reference). - - References - ---------- - See :cite:`turk1994zippered` for more informations. - - Examples - -------- - >>> from pygsp import pointclouds - >>> bunny = pointclouds.PointCloud('bunny') - >>> Xin = bunny.Xin - - """ - - def __init__(self, pointcloudname, max_dim=2): - if pointcloudname == "airfoil": - airfoilmat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'airfoil.mat')) - self.i_inds = airfoilmat['i_inds'] - self.j_inds = airfoilmat['j_inds'] - self.x = airfoilmat['x'] - self.y = airfoilmat['y'] - self.coords = np.concatenate((self.x, self.y), axis=1) - - elif pointcloudname == "bunny": - bunnymat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'bunny.mat')) - self.Xin = bunnymat["bunny"] - - elif pointcloudname == "david64": - david64mat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'david64.mat')) - self.W = david64mat["W"] - self.N = david64mat["N"][0, 0] - self.coords = david64mat["coords"] - - elif pointcloudname == "david500": - david500mat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'david500.mat')) - self.W = david500mat["W"] - self.N = david500mat["N"][0, 0] - self.coords = david500mat["coords"] - - elif pointcloudname == "logo": - logomat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'logogsp.mat')) - self.W = logomat["W"] - self.coords = logomat["coords"] - self.limits = np.array([0, 640, -400, 0]) - - self.info = {"idx_g": logomat["idx_g"], - "idx_s": logomat["idx_s"], - "idx_p": logomat["idx_p"]} - - elif pointcloudname == "minnesota": - minnesotamat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'minnesota.mat')) - self.A = minnesotamat["A"] - self.labels = minnesotamat["labels"] - self.coords = minnesotamat["xy"] - - elif pointcloudname == "two_moons": - twomoonsmat = io.loadmat(path.join(path.dirname( - path.realpath(__file__)), 'misc', 'two_moons.mat')) - if max_dim == -1: - max_dim == 2 - self.Xin = twomoonsmat["features"][:max_dim].T - - else: - raise ValueError("This PointCloud does not exist. Please verify " - "you wrote the right name in lower case.") - - def plot(self, **kwargs): - r""" - Plot the pointcloud. - - See :func:`pygsp.plotting.plot_pointcloud`. - """ - from pygsp import plotting - plotting.plot_pointcloud(self, **kwargs) diff --git a/pygsp/utils.py b/pygsp/utils.py index 6a9215d9..40260049 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -7,10 +7,13 @@ import logging from functools import wraps +from pkgutil import get_data +from io import BytesIO import numpy as np from scipy import kron, ones from scipy import sparse +from scipy.io import loadmat as scipy_loadmat def build_logger(name, **kwargs): @@ -87,6 +90,34 @@ def inner(*args, **kwargs): return func(*args, **kwargs) +def loadmat(path): + r""" + Load a matlab data file. + + Parameters + ---------- + path : string + Path to the mat file from the data folder, without the .mat extension. + + Returns + ------- + data : dict + dictionary with variable names as keys, and loaded matrices as + values. + + Examples + -------- + >>> from pygsp.utils import loadmat + >>> data = loadmat('pointclouds/bunny') + >>> data['bunny'].shape + (2503, 3) + + """ + data = get_data('pygsp', 'data/' + path + '.mat') + data = BytesIO(data) + return scipy_loadmat(data) + + def distanz(x, y=None): r""" Calculate the distance between two colon vectors. diff --git a/setup.py b/setup.py index ba96a542..20920a3c 100644 --- a/setup.py +++ b/setup.py @@ -12,10 +12,10 @@ long_description=open('README.rst').read(), author='EPFL LTS2', url='https://github.com/epfl-lts2/pygsp', - packages=['pygsp', 'pygsp.filters', 'pygsp.graphs', - 'pygsp.graphs.nngraphs', 'pygsp.operators', - 'pygsp.pointclouds', 'pygsp.tests'], - package_data={'pygsp.pointclouds': ['misc/*.mat']}, + packages=['pygsp', 'pygsp.filters', + 'pygsp.graphs', 'pygsp.graphs.nngraphs', + 'pygsp.operators', 'pygsp.tests'], + package_data={'pygsp': ['data/pointclouds/*.mat']}, test_suite='pygsp.tests.test_all.suite', install_requires=[ 'numpy', From b474cc8c3f6f4969e4395aaf8b438400ea1da830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 16 Aug 2017 16:55:50 +0200 Subject: [PATCH 113/392] graphs: standardize imports --- pygsp/graphs/barabasialbert.py | 10 +++++----- pygsp/graphs/comet.py | 10 +++++----- pygsp/graphs/community.py | 16 +++++++++------- pygsp/graphs/erdosrenyi.py | 10 +++++----- pygsp/graphs/fullconnected.py | 4 ++-- pygsp/graphs/graph.py | 9 ++++----- pygsp/graphs/grid2d.py | 17 +++++++++-------- pygsp/graphs/lowstretchtree.py | 8 ++++---- pygsp/graphs/nngraphs/cube.py | 4 ++-- pygsp/graphs/nngraphs/grid2dimgpatches.py | 3 +-- pygsp/graphs/nngraphs/nngraph.py | 9 +++++---- pygsp/graphs/nngraphs/sphere.py | 4 ++-- pygsp/graphs/path.py | 10 +++++----- pygsp/graphs/randomregular.py | 10 +++++----- pygsp/graphs/randomring.py | 8 ++++---- pygsp/graphs/ring.py | 10 +++++----- pygsp/graphs/sensor.py | 15 +++++++-------- pygsp/graphs/stochasticblockmodel.py | 4 ++-- pygsp/graphs/swissroll.py | 9 ++++----- pygsp/graphs/torus.py | 10 +++++----- 20 files changed, 90 insertions(+), 90 deletions(-) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 88d35367..5b96d141 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from . import Graph -from pygsp.utils import build_logger - import numpy as np -from scipy import sparse +from scipy.sparse import lil_matrix + +from . import Graph +from ..utils import build_logger class BarabasiAlbert(Graph): @@ -42,7 +42,7 @@ def __init__(self, N=1000, m0=1, m=1, **kwargs): raise ValueError("GSP_BarabasiAlbert: The parameter m " "cannot be above m0.") - W = sparse.lil_matrix((N, N)) + W = lil_matrix((N, N)) for i in range(m0, N): distr = W.sum(axis=1) diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 4139a535..885378c0 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class Comet(Graph): @@ -34,8 +34,8 @@ def __init__(self, Nv=32, k=12, **kwargs): np.arange(k + 1, Nv), np.arange(k, Nv - 1))) - W = sparse.csc_matrix((np.ones(np.size(i_inds)), (i_inds, j_inds)), - shape=(Nv, Nv)) + W = csc_matrix((np.ones(np.size(i_inds)), (i_inds, j_inds)), + shape=(Nv, Nv)) tmpcoords = np.zeros((Nv, 2)) inds = np.arange(k) + 1 diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 14722032..e0cadc13 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- -from . import Graph -from pygsp.utils import build_logger - from collections import Counter from copy import deepcopy + import numpy as np -from scipy import sparse, spatial +from scipy.sparse import coo_matrix +from scipy.spatial import KDTree + +from . import Graph +from ..utils import build_logger class Community(Graph): @@ -139,7 +141,7 @@ def __init__(self, N=256, **kwargs): elif k_neigh: comm_coords = coords[first_node:first_node + com_siz] - kdtree = spatial.KDTree(comm_coords) + kdtree = KDTree(comm_coords) __, indices = kdtree.query(comm_coords, k=k_neigh + 1) pairs_set = set() @@ -151,7 +153,7 @@ def __init__(self, N=256, **kwargs): else: comm_coords = coords[first_node:first_node + com_siz] - kdtree = spatial.KDTree(comm_coords) + kdtree = KDTree(comm_coords) pairs_set = kdtree.query_pairs(epsilon) w_data[0] += [1] * len(pairs_set) @@ -198,7 +200,7 @@ def __init__(self, N=256, **kwargs): w_data[1][1] += tmp_w_data w_data[1] = tuple(w_data[1]) - W = sparse.coo_matrix(tuple(w_data), shape=(N, N)) + W = coo_matrix(tuple(w_data), shape=(N, N)) for key, value in {'Nc': Nc, 'info': info}.items(): setattr(self, key, value) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index edbf09ad..26daf044 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -from . import Graph -from pygsp.utils import build_logger - import numpy as np -from scipy import sparse +from scipy.sparse import csr_matrix + +from . import Graph +from ..utils import build_logger class ErdosRenyi(Graph): @@ -61,7 +61,7 @@ def __init__(self, N=100, p=0.1, **kwargs): else: indices = tuple(map(lambda coord: coord[indices], np.tril_indices(N, -1))) - matrix = sparse.csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) + matrix = csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) self.W = matrix if directed else matrix + matrix.T self.A = self.W > 0 is_connected = self.is_connected() diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index 518861ea..cad4bcc5 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np +from . import Graph + class FullConnected(Graph): r""" diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e50d759d..60f6bcf8 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -import math from collections import Counter import numpy as np -import scipy as sp from scipy import sparse +from scipy.linalg import svd from ..utils import build_logger @@ -156,7 +155,7 @@ def check_weights(self): is_not_square = False has_nan_value = False - if math.isinf(self.W.sum()): + if np.isinf(self.W.sum()): self.logger.warning("GSP_TEST_WEIGHTS: There is an infinite " "value in the weight matrix") has_inf_val = True @@ -171,7 +170,7 @@ def check_weights(self): "not square!") is_not_square = True - if math.isnan(self.W.sum()): + if np.isnan(self.W.sum()): self.logger.warning("GSP_TEST_WEIGHTS: There is an NaN " "value in the weight matrix") has_nan_value = True @@ -636,7 +635,7 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, if not hasattr(self, 'L'): raise AttributeError("Graph Laplacian is missing") - eigenvectors, eigenvalues, _ = sp.linalg.svd(self.L.todense()) + eigenvectors, eigenvalues, _ = svd(self.L.todense()) inds = np.argsort(eigenvalues) if not smallest_first: diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index d2a3733b..e181be14 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy import sparse +from scipy.sparse import diags + from . import Graph -from .. import utils +from ..utils import symmetrize class Grid2d(Graph): @@ -47,12 +48,12 @@ def __init__(self, shape=(3,), **kwargs): diag_1[(w - 1)::w] = 0 stride = w diag_2 = np.ones((h * w - stride,)) - W = sparse.diags(diagonals=[diag_1, diag_2], - offsets=[-1, -stride], - shape=(h * w, h * w), - format='csr', - dtype='float') - W = utils.symmetrize(W, symmetrize_type='full') + W = diags(diagonals=[diag_1, diag_2], + offsets=[-1, -stride], + shape=(h * w, h * w), + format='csr', + dtype='float') + W = symmetrize(W, symmetrize_type='full') x = np.kron(np.ones((h, 1)), (np.arange(w) / float(w)).reshape(w, 1)) y = np.kron(np.ones((w, 1)), np.arange(h) / float(h)).reshape(h * w, 1) diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index 8db28740..c47e119a 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class LowStretchTree(Graph): @@ -48,7 +48,7 @@ def __init__(self, k=6, **kwargs): XCoords = np.concatenate((XCoords, XCoords + 2**p)) XCoords = np.kron(np.ones((2)), XCoords) - W = sparse.csc_matrix((np.ones((np.shape(ii))), (ii, jj))) + W = csc_matrix((np.ones((np.shape(ii))), (ii, jj))) coords = np.concatenate((XCoords[:, np.newaxis], YCoords[:, np.newaxis]), axis=1) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 37b34c16..2d2e7a88 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import NNGraph - import numpy as np +from . import NNGraph + class Cube(NNGraph): r""" diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index a110b66f..86b51d3b 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- from . import ImgPatches -from .. import Grid2d -from ..graph import Graph +from .. import Graph, Grid2d class Grid2dImgPatches(Graph): diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 2a047ee7..1de9f653 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import numpy as np +from scipy.sparse import csc_matrix +from scipy.spatial import KDTree from .. import Graph from ...utils import symmetrize -from scipy import sparse, spatial try: import pyflann as fl @@ -123,7 +124,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, algorithm='kdtree') else: - kdt = spatial.KDTree(Xout) + kdt = KDTree(Xout) D, NN = kdt.query(Xout, k=(k + 1), p=dist_translation[dist_type]) @@ -135,7 +136,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, elif self.NNtype == 'radius': - kdt = spatial.KDTree(Xout) + kdt = KDTree(Xout) D, NN = kdt.query(Xout, k=None, distance_upper_bound=epsilon, p=dist_translation[dist_type]) count = 0 @@ -158,7 +159,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, else: raise ValueError('Unknown type : allowed values are knn, radius') - W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) + W = csc_matrix((spv, (spi, spj)), shape=(N, N)) # Sanity check if np.shape(W)[0] != np.shape(W)[1]: diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index e3084c8d..05f85123 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import NNGraph - import numpy as np +from . import NNGraph + class Sphere(NNGraph): r""" diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index 56cdc1c4..fba50e4b 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class Path(Graph): @@ -31,8 +31,8 @@ def __init__(self, N=16): inds_i = np.concatenate((np.arange(N - 1), np.arange(1, N))) inds_j = np.concatenate((np.arange(1, N), np.arange(N - 1))) - W = sparse.csc_matrix((np.ones((2*(N - 1))), (inds_i, inds_j)), - shape=(N, N)) + W = csc_matrix((np.ones((2*(N - 1))), (inds_i, inds_j)), + shape=(N, N)) coords = np.concatenate(((np.arange(N) + 1)[:, np.newaxis], np.zeros((N, 1))), axis=1) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 9e66607b..93b9d81f 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- +import numpy as np +from scipy.sparse import lil_matrix + from . import Graph from ..utils import build_logger -import numpy as np -from scipy import sparse - class RandomRegular(Graph): r""" @@ -55,7 +55,7 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): U = np.kron(np.ones(k), np.arange(N)) # the graphs adjacency matrix - A = sparse.lil_matrix(np.zeros((N, N))) + A = lil_matrix(np.zeros((N, N))) edgesTested = 0 repetition = 1 @@ -82,7 +82,7 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): repetition = repetition + 1 edgesTested = 0 U = np.kron(np.ones(k), np.arange(N)) - A = sparse.lil_matrix(np.zeros((N, N))) + A = lil_matrix(np.zeros((N, N))) else: # add edge to graph A[v1, v2] = 1 diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index bf2001eb..f2acc09e 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class RandomRing(Graph): @@ -32,7 +32,7 @@ def __init__(self, N=64): inds_j = np.arange(1, N) inds_i = np.arange(N - 1) - W = sparse.csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) + W = csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) W = W.tolil() W[N - 1, 0] = weightend W = W + W.T diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index ae5cbacd..536c8dd1 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class Ring(Graph): @@ -49,8 +49,8 @@ def __init__(self, N=64, k=1, **kwargs): i_inds[2*N*(k - 1) + tmpN] = tmpN i_inds[2*N*(k - 1) + tmpN] = np.remainder(tmpN + k + 1, N) - W = sparse.csc_matrix((np.ones((2*num_edges)), (i_inds, j_inds)), - shape=(N, N)) + W = csc_matrix((np.ones((2*num_edges)), (i_inds, j_inds)), + shape=(N, N)) plotting = {'limits': np.array([-1, 1, -1, 1])} diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index e887b226..6737573a 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- +import numpy as np +from scipy.sparse import lil_matrix, csc_matrix + from . import Graph from ..utils import distanz -import numpy as np -from scipy import sparse -from math import ceil, sqrt, log - class Sensor(Graph): r""" @@ -57,7 +56,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, else: W, coords = self._create_weight_matrix(N, distribute, regular, Nc) - W = sparse.lil_matrix(W) + W = lil_matrix(W) W = (W + W.T) / 2. gtype = 'regular sensor' if self.regular else 'sensor' @@ -88,7 +87,7 @@ def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): YCoords = np.zeros((N, 1)) if param_distribute: - mdim = int(ceil(sqrt(N))) + mdim = int(np.ceil(np.sqrt(N))) for i in range(mdim): for j in range(mdim): if i*mdim + j < N: @@ -105,7 +104,7 @@ def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): # Compute the distanz between all the points target_dist_cutoff = 2*N**(-0.5) T = 0.6 - s = sqrt(-target_dist_cutoff**2/(2*log(T))) + s = np.sqrt(-target_dist_cutoff**2/(2*np.log(T))) d = distanz(x=coords.T) W = np.exp(-d**2/(2.*s**2)) W -= np.diag(np.diag(W)) @@ -118,5 +117,5 @@ def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): W = np.where(W < T, 0, W) W = np.where(W2 > 0, W2, W) - W = sparse.csc_matrix(W) + W = csc_matrix(W) return W, coords diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 4bd5fddf..f5ff9413 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy import sparse +from scipy.sparse import csr_matrix from . import Graph @@ -92,7 +92,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, nb_row = 0 nb_col += 1 - W = sparse.csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) + W = csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) if undirected: W = W + W.T diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 8f89d944..ebe03961 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- +import numpy as np + from . import Graph from ..utils import distanz, rescale_center -import numpy as np -from math import sqrt, pi - class SwissRoll(Graph): r""" @@ -42,7 +41,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, noise=False, srtype='uniform'): if s is None: - s = sqrt(2. / N) + s = np.sqrt(2. / N) y1 = np.random.rand(N) y2 = np.random.rand(N) @@ -51,7 +50,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, tt = np.sqrt((b * b - a * a) * y1 + a * a) elif srtype == 'classic': tt = (b - a) * y1 + a - tt *= pi + tt *= np.pi if dim == 2: x = np.array((tt * np.cos(tt), tt * np.sin(tt))) diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index b9a46628..072f21f7 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -from . import Graph - import numpy as np -from scipy import sparse +from scipy.sparse import csc_matrix + +from . import Graph class Torus(Graph): @@ -65,8 +65,8 @@ def __init__(self, Nv=16, Mv=None, **kwargs): j_inds[K*Mv + (Mv - 1)*2*Nv + tmp2Nv] = \ np.concatenate(((Mv - 1)*Nv + tmpNv, tmpNv)) - W = sparse.csc_matrix((np.ones((K*Mv + J*Nv)), (i_inds, j_inds)), - shape=(Mv*Nv, Mv*Nv)) + W = csc_matrix((np.ones((K*Mv + J*Nv)), (i_inds, j_inds)), + shape=(Mv*Nv, Mv*Nv)) # Create coordinate T = 1.5 + np.sin(np.arange(Mv)*2*np.pi/Mv).reshape(1, Mv) From 808d0fa23d20f7d0b5c4a300a65f8eafeb54c827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 16 Aug 2017 17:23:52 +0200 Subject: [PATCH 114/392] doc: navigation depth = 2 --- doc/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 6f4988d7..cafa5e42 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,6 +21,9 @@ pygments_style = 'sphinx' html_theme = 'sphinx_rtd_theme' +html_theme_options = { + 'navigation_depth': 2, +} latex_elements = { 'papersize': 'a4paper', 'pointsize': '10pt', From 9fa296235ecd6d8a9f77eec1228133909f50eb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 00:36:57 +0200 Subject: [PATCH 115/392] packages: automatically import content of __all__ --- pygsp/__init__.py | 38 +++++-------- pygsp/filters/__init__.py | 37 ++++++++++--- pygsp/graphs/__init__.py | 65 ++++++++++++----------- pygsp/graphs/graph.py | 4 +- pygsp/graphs/nngraphs/__init__.py | 20 ------- pygsp/graphs/nngraphs/bunny.py | 4 +- pygsp/graphs/nngraphs/cube.py | 2 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 7 ++- pygsp/graphs/nngraphs/imgpatches.py | 8 +-- pygsp/graphs/nngraphs/nngraph.py | 4 +- pygsp/graphs/nngraphs/sphere.py | 2 +- pygsp/graphs/nngraphs/twomoons.py | 4 +- pygsp/operators/__init__.py | 40 ++++++++++++-- pygsp/utils.py | 23 ++++++++ 14 files changed, 154 insertions(+), 104 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 686af80c..c6362e0b 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -13,30 +13,20 @@ subsequent pages. """ -# When importing the toolbox, you surely want these modules. -from pygsp import data_handling -from pygsp import optimization -from pygsp import plotting -from pygsp import utils -from pygsp import filters -from pygsp import graphs - -# Module features have to be imported after graphs and filters, otherwise we -# get a circular dependency error. -from pygsp import features - -from pygsp import operators - - -# Silence the code checker warning about unused symbols. -assert data_handling -assert filters -assert graphs -assert operators -assert optimization -assert features -assert plotting -assert utils +from pygsp import utils as _utils + +__all__ = [ + 'graphs', + 'filters', + 'operators', + 'plotting', + 'features', + 'data_handling', + 'optimization', + 'utils', +] + +_utils.import_modules(__all__[::-1], 'pygsp', 'pygsp') __version__ = '0.4.2' __release_date__ = '2017-04-27' diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 51940547..fe9964f8 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -9,14 +9,35 @@ For specific information, :ref:`see details here`. """ -import importlib -import sys +from pygsp import utils as _utils -__all__ = ['Filter', 'Abspline', 'Expwin', 'Gabor', 'HalfCosine', 'Heat', 'Held', 'Itersine', 'MexicanHat', 'Meyer', - 'Papadakis', 'Regular', 'Simoncelli', 'SimpleTf', 'WarpedTranslates'] +_FILTERS = [ + 'Filter', + 'Abspline', + 'Expwin', + 'Gabor', + 'HalfCosine', + 'Heat', + 'Held', + 'Itersine', + 'MexicanHat', + 'Meyer', + 'Papadakis', + 'Regular', + 'Simoncelli', + 'SimpleTf', + 'WarpedTranslates' +] +_APPROXIMATIONS = [ + 'compute_cheby_coeff', + 'compute_jackson_cheby_coeff', + 'cheby_op', + 'cheby_rect', + 'lanczos', + 'lanczos_op' +] -# Automaticaly import all classes from subfiles defined in __all__ -for class_to_import in __all__: - setattr(sys.modules[__name__], class_to_import, getattr(importlib.import_module('.' + class_to_import.lower(), 'pygsp.filters'), class_to_import)) +__all__ = _FILTERS + _APPROXIMATIONS -from .approximations import * +_utils.import_classes(_FILTERS, 'filters', 'filters') +_utils.import_functions(_APPROXIMATIONS, 'filters.approximations', 'filters') diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index c9c2886f..57f21941 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -7,36 +7,41 @@ which depends on the particular graph you are trying to build. For specific information, :ref:`see details here`. """ -import importlib -import sys +from pygsp import utils as _utils -__all__ = ['Graph', - 'Airfoil', - 'BarabasiAlbert', - 'Comet', - 'Community', - 'DavidSensorNet', - 'ErdosRenyi', - 'FullConnected', - 'Grid2d', - 'Logo', - 'LowStretchTree', - 'Minnesota', - 'Path', - 'RandomRegular', - 'RandomRing', - 'Ring', - 'Sensor', - 'StochasticBlockModel', - 'SwissRoll', - 'Torus'] +_GRAPHS = [ + 'Graph', + 'Airfoil', + 'BarabasiAlbert', + 'Comet', + 'Community', + 'DavidSensorNet', + 'ErdosRenyi', + 'FullConnected', + 'Grid2d', + 'Logo', + 'LowStretchTree', + 'Minnesota', + 'Path', + 'RandomRegular', + 'RandomRing', + 'Ring', + 'Sensor', + 'StochasticBlockModel', + 'SwissRoll', + 'Torus' +] +_NNGRAPHS = [ + 'NNGraph', + 'Bunny', + 'Cube', + 'ImgPatches', + 'Grid2dImgPatches', + 'Sphere', + 'TwoMoons' +] +__all__ = _GRAPHS + _NNGRAPHS -# Automaticaly import all classes from subfiles defined in __all__ -for class_to_import in __all__: - setattr(sys.modules[__name__], class_to_import, - getattr(importlib.import_module('.' + class_to_import.lower(), - 'pygsp.graphs'), - class_to_import)) - -from .nngraphs import * +_utils.import_classes(_GRAPHS, 'graphs', 'graphs') +_utils.import_classes(_NNGRAPHS, 'graphs.nngraphs', 'graphs') diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 60f6bcf8..e211aa4f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -6,7 +6,7 @@ from scipy import sparse from scipy.linalg import svd -from ..utils import build_logger +from pygsp.utils import build_logger class Graph(object): @@ -238,7 +238,7 @@ def update_graph_attr(self, *args, **kwargs): 'the valid attributes, which are ' '{}').format(i, valid_attributes)) - from .nngraphs import NNGraph + from pygsp.graphs import NNGraph if isinstance(self, NNGraph): super(NNGraph, self).__init__(**graph_attr) else: diff --git a/pygsp/graphs/nngraphs/__init__.py b/pygsp/graphs/nngraphs/__init__.py index e7175b92..e69de29b 100644 --- a/pygsp/graphs/nngraphs/__init__.py +++ b/pygsp/graphs/nngraphs/__init__.py @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- - -import importlib -import sys - - -__all__ = ['NNGraph', - 'Bunny', - 'Cube', - 'ImgPatches', - 'Grid2dImgPatches', - 'Sphere', - 'TwoMoons'] - -for class_to_import in __all__: - setattr(sys.modules[__name__], - class_to_import, - getattr(importlib.import_module('.' + class_to_import.lower(), - 'pygsp.graphs.nngraphs'), - class_to_import)) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index fa1bee38..a4c7ad99 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from . import NNGraph -from ...utils import loadmat +from pygsp.graphs import NNGraph +from pygsp.utils import loadmat class Bunny(NNGraph): diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 2d2e7a88..a4f14cd9 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -2,7 +2,7 @@ import numpy as np -from . import NNGraph +from pygsp.graphs import NNGraph class Cube(NNGraph): diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 86b51d3b..dad5d99c 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- -from . import ImgPatches -from .. import Graph, Grid2d +from pygsp.graphs import Graph, Grid2d, ImgPatches class Grid2dImgPatches(Graph): @@ -25,10 +24,10 @@ class Grid2dImgPatches(Graph): Examples -------- - >>> from pygsp.graphs import nngraphs + >>> from pygsp import graphs >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::32, ::32]) - >>> G = nngraphs.Grid2dImgPatches(img) + >>> G = graphs.Grid2dImgPatches(img) """ diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index b9cdbb2d..e45aab0f 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from . import NNGraph -from ...features import patch_features +from pygsp.graphs import NNGraph +from pygsp.features import patch_features class ImgPatches(NNGraph): @@ -23,10 +23,10 @@ class ImgPatches(NNGraph): Examples -------- - >>> from pygsp.graphs import nngraphs + >>> from pygsp import graphs >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::2, ::2]) - >>> G = nngraphs.ImgPatches(img) + >>> G = graphs.ImgPatches(img) """ diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 1de9f653..ff245a94 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -4,8 +4,8 @@ from scipy.sparse import csc_matrix from scipy.spatial import KDTree -from .. import Graph -from ...utils import symmetrize +from pygsp.graphs import Graph +from pygsp.utils import symmetrize try: import pyflann as fl diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 05f85123..0d226034 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -2,7 +2,7 @@ import numpy as np -from . import NNGraph +from pygsp.graphs import NNGraph class Sphere(NNGraph): diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 7bbabdf4..969a66d6 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -2,8 +2,8 @@ import numpy as np -from . import NNGraph -from ...utils import loadmat +from pygsp.graphs import NNGraph +from pygsp.utils import loadmat class TwoMoons(NNGraph): diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index 1efef7ce..abacdf7f 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -1,6 +1,38 @@ # -*- coding: utf-8 -*- -from .difference import * -from .transforms import * -from .localization import * -from .reduction import * +from pygsp import utils as _utils + +_DIFFERENCE = [ + 'grad_mat', + 'grad', + 'div', +] +_TRANSFORMS = [ + 'gft', + 'igft', + 'generalized_wft', + 'gabor_wft', + 'ngwft', +] +_LOCALIZATION = [ + 'localize', + 'modulate', + 'translate', +] +_REDUCTION = [ + 'tree_multiresolution', + 'graph_multiresolution', + 'kron_reduction', + 'pyramid_analysis', + 'pyramid_synthesis', + 'pyramid_cell2coeff', + 'interpolate', + 'graph_sparsify', +] + +__all__ = _DIFFERENCE + _TRANSFORMS + _LOCALIZATION + _REDUCTION + +_utils.import_functions(_DIFFERENCE, 'operators.difference', 'operators') +_utils.import_functions(_TRANSFORMS, 'operators.transforms', 'operators') +_utils.import_functions(_LOCALIZATION, 'operators.localization', 'operators') +_utils.import_functions(_REDUCTION, 'operators.reduction', 'operators') diff --git a/pygsp/utils.py b/pygsp/utils.py index 40260049..3b8ff110 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -5,6 +5,8 @@ the package. """ +import sys +import importlib import logging from functools import wraps from pkgutil import get_data @@ -297,3 +299,24 @@ def rescale_center(x): r = y / c return r + + +def import_modules(names, src, dst): + """Import modules in package.""" + for name in names: + module = importlib.import_module(src + '.' + name) + setattr(sys.modules[dst], name, module) + + +def import_classes(names, src, dst): + """Import classes in package from their implementation modules.""" + for name in names: + module = importlib.import_module('pygsp.' + src + '.' + name.lower()) + setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) + + +def import_functions(names, src, dst): + """Import functions in package from their implementation modules.""" + for name in names: + module = importlib.import_module('pygsp.' + src) + setattr(sys.modules['pygsp.' + dst], name, getattr(module, name)) From 9226a7cef95e3c792c6a91019079f5b147c56dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 00:39:09 +0200 Subject: [PATCH 116/392] tutorials: consistent import --- doc/tutorials/graph_tv.rst | 2 +- doc/tutorials/intro.rst | 8 ++++---- doc/tutorials/pyramid.rst | 17 ++++++++--------- doc/tutorials/wavelet.rst | 14 +++++++------- 4 files changed, 20 insertions(+), 21 deletions(-) diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst index dbfadc4f..82a65e92 100644 --- a/doc/tutorials/graph_tv.rst +++ b/doc/tutorials/graph_tv.rst @@ -39,8 +39,8 @@ It is simply a projection on the B2-ball. Results and code ---------------- ->>> from pygsp import graphs >>> import numpy as np +>>> from pygsp import graphs >>> >>> # Create a random sensor graph >>> G = graphs.Sensor(N=256, distribute=True) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 6f358c27..8b751138 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -5,21 +5,21 @@ Introduction to the PyGSP This tutorial shows basic operations of the toolbox. To start open a python shell (IPython is recommended here) and import the pygsp. You would probably also import numpy as you will need it to create matrices and arrays. ->>> import pygsp >>> import numpy as np +>>> from pygsp import graphs, filters The first step is to create a graph, there's a general class that can be used to generate graph from it's weight matrix. >>> np.random.seed(42) # We will use a seed to make reproducible results >>> W = np.random.rand(400, 400) ->>> G = pygsp.graphs.Graph(W) +>>> G = graphs.Graph(W) You have now a graph structure ready to be used everywhere in the box! Check the :mod:`pygsp.graphs` module to know more about the Graph class and it's subclasses. You can also check the included methods for all graphs with the usual help function. For the next steps of the demo, we will be using the logo graph bundled with the toolbox : ->>> G = pygsp.graphs.Logo() +>>> G = graphs.Logo() You can now plot the graph: @@ -49,7 +49,7 @@ Let's discover basic filters operations, filters are usually defined in the spec First let's define a filter object: ->>> F = pygsp.filters.Filter(G) +>>> F = filters.Filter(G) And we can assign this function diff --git a/doc/tutorials/pyramid.rst b/doc/tutorials/pyramid.rst index 60494b9a..5f8254a6 100644 --- a/doc/tutorials/pyramid.rst +++ b/doc/tutorials/pyramid.rst @@ -6,18 +6,17 @@ In this demonstration file, we show how to reduce a graph using the PyGSP. Then To start open a python shell (IPython is recommended here) and import the required packages. You would probably also import numpy as you will need it to create matrices and arrays. >>> import numpy as np ->>> from pygsp.graphs import Sensor ->>> from pygsp.operators import graph_multiresolution, pyramid_cell2coeff, pyramid_analysis, pyramid_synthesis +>>> from pygsp import graphs, operators -For this demo we will be using a Sensor graph with 512 nodes. +For this demo we will be using a sensor graph with 512 nodes. ->>> G = Sensor(512, distribute=True) +>>> G = graphs.Sensor(512, distribute=True) >>> G.compute_fourier_basis() The function graph_multiresolution computes the graph pyramid for you: >>> levels = 5 ->>> Gs = graph_multiresolution(G, levels, sparsify=False) +>>> Gs = operators.graph_multiresolution(G, levels, sparsify=False) Next, we will compute the fourier basis of our different graph layers: @@ -39,13 +38,13 @@ Let's now create two signals and a filter, resp f, f2 and g: We will run the analysis of the two signals on the pyramid and obtain a coarse approximation for each layer, with decreasing number of nodes. Additionally, we will also get prediction errors at each node at every layer. ->>> ca, pe = pyramid_analysis(Gs, f, h_filters=g) ->>> ca2, pe2 = pyramid_analysis(Gs, f2, h_filters=g) +>>> ca, pe = operators.pyramid_analysis(Gs, f, h_filters=g) +>>> ca2, pe2 = operators.pyramid_analysis(Gs, f2, h_filters=g) Given the pyramid, the coarsest approximation and the prediction errors, we will now reconstruct the original signal on the full graph. ->>> f_pred, _ = pyramid_synthesis(Gs, ca[levels], pe) ->>> f_pred2, _ = pyramid_synthesis(Gs, ca2[levels], pe2) +>>> f_pred, _ = operators.pyramid_synthesis(Gs, ca[levels], pe) +>>> f_pred2, _ = operators.pyramid_synthesis(Gs, ca2[levels], pe2) Here are the final errors for each signal after reconstruction. diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index 6e938c89..c3f5a48b 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -10,9 +10,9 @@ The wavelets are a special type of filterbank, in this demo we will show you how In this demo we will show you how to compute the wavelet coefficients of a graph and visualize them. First let's import the toolbox, numpy and load a graph. ->>> import pygsp >>> import numpy as np ->>> G = pygsp.graphs.Bunny() +>>> from pygsp import graphs, filters +>>> G = graphs.Bunny() This graph is a nearest-neighbor graph of a pointcloud of the Stanford bunny. It will allow us to get interesting visual results using wavelets. @@ -28,7 +28,7 @@ Simple filtering Before tackling wavelets, we can see the effect of one filter localized on the graph. So we can first design a few heat kernel filters >>> taus = [1, 10, 25, 50] ->>> Hk = pygsp.filters.Heat(G, taus, normalize=False) +>>> Hk = filters.Heat(G, taus, normalize=False) Let's now create a signal as a Kronecker located on one vertex (e.g. the vertex 83) @@ -72,10 +72,10 @@ Let's plot the signal: Visualizing wavelets atoms -------------------------- -Let's now replace the Heat filter by a filter bank of wavelets. We can create a filter bank using one of the predefined filters such as pygsp.filters.MexicanHat. +Let's now replace the Heat filter by a filter bank of wavelets. We can create a filter bank using one of the predefined filters such as :func:`pygsp.filters.MexicanHat`. >>> Nf = 6 ->>> Wk = pygsp.filters.MexicanHat(G, Nf) +>>> Wk = filters.MexicanHat(G, Nf) We can now plot the filter bank spectrum : @@ -113,8 +113,8 @@ We can visualize the filtering by one atom the same way the did for the Heat ker .. figure:: img/wavelet_3.* .. figure:: img/wavelet_4.* ->>> G = pygsp.graphs.Bunny() ->>> Wk = pygsp.filters.MexicanHat(G, Nf) +>>> G = graphs.Bunny() +>>> Wk = filters.MexicanHat(G, Nf) >>> s_map = G.coords >>> s_map_out = Wk.analysis(s_map) From 53969813a2038890430062529a142b5b32e843ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 00:39:54 +0200 Subject: [PATCH 117/392] good package documentation --- pygsp/__init__.py | 23 +++++++++------ pygsp/filters/__init__.py | 56 +++++++++++++++++++++++++++++++++---- pygsp/graphs/__init__.py | 41 +++++++++++++++++++++++++-- pygsp/operators/__init__.py | 36 ++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index c6362e0b..82c79040 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -1,16 +1,21 @@ # -*- coding: utf-8 -*- -""" -This toolbox is splitted in different modules taking care of the different -aspects of Graph Signal Processing. +r""" +The :mod:`pygsp` package is mainly organized around the following three +modules: + +* :mod:`pygsp.graphs` to create and manipulate various kinds of graphs, +* :mod:`pygsp.filters` to create and manipulate various graph filters, +* :mod:`pygsp.operators` to apply various operators to graph signals. + +Moreover, the following modules provide additional functionality: -Those modules are : :ref:`Graphs `, :ref:`Filters `, -:ref:`Operators `, :ref:`PointCloud `, -:ref:`Plotting `, :ref:`Data Handling ` and -:ref:`Utils `. +* :mod:`pygsp.plotting` to plot, +* :mod:`pygsp.features` to compute features on graphs, +* :mod:`pygsp.data_handling` to manipulate data, +* :mod:`pygsp.optimization` to help solving convex optimization problems, +* :mod:`pygsp.utils` for various utilities. -You can find detailed documentation on the use of the functions in the -subsequent pages. """ from pygsp import utils as _utils diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index fe9964f8..697f9e5e 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -1,12 +1,58 @@ # -*- coding: utf-8 -*- r""" -This module implements filters and contains predefined filters that can be directly applied to graphs. +The :mod:`pygsp.filters` module implements methods used for filtering (e.g. +analysis, synthesis, evaluation) and defines commonly used filters that can be +applied to :mod:`pygsp.graphs`. A filter is associated to a graph and is +defined with one or several functions. We define by filterbank a list of +filters, usually centered around different frequencies, applied to a single +graph. + +See the :class:`pygsp.filters.Filter` base class for the documentation of the +interface to the filter object. Derived classes implement various common graph +filters. + +**Filterbank of N filters** + +* :class:`pygsp.filters.Abspline` +* :class:`pygsp.filters.Gabor` +* :class:`pygsp.filters.HalfCosine` +* :class:`pygsp.filters.Itersine` +* :class:`pygsp.filters.MexicanHat` +* :class:`pygsp.filters.Meyer` +* :class:`pygsp.filters.SimpleTf` +* :class:`pygsp.filters.WarpedTranslates` + +**Filterbank of 2 filters: low pass and high pass** + +* :class:`pygsp.filters.Regular` +* :class:`pygsp.filters.Held` +* :class:`pygsp.filters.Simoncelli` +* :class:`pygsp.filters.Papadakis` + +**Low pass filter** + +* :class:`pygsp.filters.Heat` +* :class:`pygsp.filters.Expwin` + +Moreover, two approximation methods are provided for fast filtering. The +computational complexity of filtering with those approximations is linear with +the number of edges. The complexity of the exact solution, which is to use the +Fourier basis, is quadratic with the number of nodes (without taking into +account the cost of the necessary eigendecomposition of the graph Laplacian). + +**Chebyshev polynomials** + +* :class:`pygsp.filters.compute_cheby_coeff` +* :class:`pygsp.filters.compute_jackson_cheby_coeff` +* :class:`pygsp.filters.cheby_op` +* :class:`pygsp.filters.cheby_rect` + +**Lanczos algorithm** + +* :class:`pygsp.filters.lanczos` +* :class:`pygsp.filters.lanczos_op` -A filter is associated to a graph and is defined with one or several function(s). -We define by Filterbank a list of filters applied to a single graph. -Tools for the analysis, the synthesis and the evaluation are provided to work with the filters on the graphs. -For specific information, :ref:`see details here`. """ from pygsp import utils as _utils diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 57f21941..e2547771 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -1,10 +1,45 @@ # -*- coding: utf-8 -*- r""" -This module implements graphs and contains predefined graphs for the most famous ones. +The :mod:`pygsp.graphs` module implements the graph class hierarchy. A graph +object is either constructed from an adjacency matrix, or by instantiating one +of the built-in graph models. + +The :class:`pygsp.graphs.Graph` base class allows to construct a graph object +from any adjacency matrix and provides a common interface to that object. + +Derived classes implement various graph models. + +* :class:`pygsp.graphs.Airfoil` +* :class:`pygsp.graphs.BarabasiAlbert` +* :class:`pygsp.graphs.Comet` +* :class:`pygsp.graphs.Community` +* :class:`pygsp.graphs.DavidSensorNet` +* :class:`pygsp.graphs.ErdosRenyi` +* :class:`pygsp.graphs.FullConnected` +* :class:`pygsp.graphs.Grid2d` +* :class:`pygsp.graphs.Logo` +* :class:`pygsp.graphs.LowStretchTree` +* :class:`pygsp.graphs.Minnesota` +* :class:`pygsp.graphs.Path` +* :class:`pygsp.graphs.RandomRegular` +* :class:`pygsp.graphs.RandomRing` +* :class:`pygsp.graphs.Ring` +* :class:`pygsp.graphs.Sensor` +* :class:`pygsp.graphs.StochasticBlockModel` +* :class:`pygsp.graphs.SwissRoll` +* :class:`pygsp.graphs.Torus` + +Derived classes from :class:`pygsp.graphs.NNGraph` implement nearest-neighbors +graphs constructed from point clouds. + +* :class:`pygsp.graphs.Bunny` +* :class:`pygsp.graphs.Cube` +* :class:`pygsp.graphs.ImgPatches` +* :class:`pygsp.graphs.Grid2dImgPatches` +* :class:`pygsp.graphs.Sphere` +* :class:`pygsp.graphs.TwoMoons` -A graph is constructed either from its adjacency matrix, its weight matrix or any other parameter -which depends on the particular graph you are trying to build. For specific information, :ref:`see details here`. """ from pygsp import utils as _utils diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index abacdf7f..91087580 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -1,5 +1,41 @@ # -*- coding: utf-8 -*- +r""" +The :mod:`pygsp.operators` module implements some operators on graphs. + +**Differential operators** + +* :func:`pygsp.operators.grad_mat`: compute the gradient sparse matrix +* :func:`pygsp.operators.grad`: compute the gradient of a signal +* :func:`pygsp.operators.div`: compute the divergence of a signal + +**Transforms** (frequency and vertex-frequency) + +* :func:`pygsp.operators.gft`: graph Fourier transform +* :func:`pygsp.operators.igft`: inverse graph Fourier transform +* :func:`pygsp.operators.generalized_wft`: graph windowed Fourier transform +* :func:`pygsp.operators.gabor_wft`: graph windowed Fourier transform +* :func:`pygsp.operators.ngwft`: normalized graph windowed Fourier transform + +**Localization** + +* :func:`pygsp.operators.localize`: localize a kernel +* :func:`pygsp.operators.modulate`: generalized modulation operator +* :func:`pygsp.operators.translate`: generalized translation operator + +**Reduction** Functionalities for the reduction of graphs' vertex set while keeping the graph structure. + +* :func:`pygsp.operators.tree_multiresolution`: compute a multiresolution of trees +* :func:`pygsp.operators.graph_multiresolution`: compute a pyramid of graphs +* :func:`pygsp.operators.kron_reduction`: compute the Kron reduction +* :func:`pygsp.operators.pyramid_analysis`: analysis operator for graph pyramid +* :func:`pygsp.operators.pyramid_synthesis`: synthesis operator for graph pyramid +* :func:`pygsp.operators.pyramid_cell2coeff`: keep only the necessary coefficients +* :func:`pygsp.operators.interpolate`: interpolate a signal +* :func:`pygsp.operators.graph_sparsify`: sparsify a graph + +""" + from pygsp import utils as _utils _DIFFERENCE = [ From 6520705dddab9813289d8c95f1d2f8f088bebf52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 02:16:54 +0200 Subject: [PATCH 118/392] plot_signal: don't create an empty figure --- pygsp/plotting.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 12f54977..e27e0b61 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -143,6 +143,9 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph + fig = plt.figure(plid.plot_id) + plid.plot_id += 1 + if not plot_name: plot_name = u"Plot of {}".format(G.gtype) @@ -157,12 +160,8 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name # Matplotlib graph initialization in 2D and 3D if G.coords.shape[1] == 2: - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 ax = fig.add_subplot(111) elif G.coords.shape[1] == 3: - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 ax = fig.add_subplot(111, projection='3d') if show_edges: @@ -524,10 +523,8 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], # Matplotlib graph initialization in 2D and 3D if G.coords.shape[1] == 2: - fig = plt.figure() ax = fig.add_subplot(111) elif G.coords.shape[1] == 3: - fig = plt.figure() ax = fig.add_subplot(111, projection='3d') # Plot edges From 62123832334ac5767b47fe16f24ac5837ba75a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 02:18:19 +0200 Subject: [PATCH 119/392] tutorials: generate figures when building doc --- doc/conf.py | 6 ++ doc/tutorials/graph_tv.rst | 119 ++++++++++++++------------ doc/tutorials/img/.gitignore | 2 - doc/tutorials/intro.rst | 89 ++++++++++++-------- doc/tutorials/wavelet.rst | 157 ++++++++++++++++------------------- 5 files changed, 200 insertions(+), 173 deletions(-) delete mode 100644 doc/tutorials/img/.gitignore diff --git a/doc/conf.py b/doc/conf.py index cafa5e42..5b0130d3 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -7,6 +7,12 @@ 'sphinx.ext.autosummary', 'sphinx.ext.mathjax', 'sphinx.ext.inheritance_diagram', 'sphinxcontrib.bibtex'] +extensions.append('matplotlib.sphinxext.plot_directive') +plot_include_source = True +plot_html_show_source_link = False +plot_html_show_formats = False +plot_working_directory = '.' + extensions.append('numpydoc') numpydoc_show_class_members = False diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst index 82a65e92..aab98039 100644 --- a/doc/tutorials/graph_tv.rst +++ b/doc/tutorials/graph_tv.rst @@ -39,60 +39,66 @@ It is simply a projection on the B2-ball. Results and code ---------------- ->>> import numpy as np ->>> from pygsp import graphs ->>> ->>> # Create a random sensor graph ->>> G = graphs.Sensor(N=256, distribute=True) ->>> G.compute_fourier_basis() ->>> ->>> # Create signal ->>> graph_value = np.copysign(np.ones(np.shape(G.U[:, 3])[0]), G.U[:, 3]) ->>> ->>> G.plot_signal(graph_value, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/original_signal') - -.. figure:: img/original_signal.* +.. plot:: + :context: reset + + >>> import numpy as np + >>> from pygsp import graphs + >>> + >>> # Create a random sensor graph + >>> G = graphs.Sensor(N=256, distribute=True) + >>> G.compute_fourier_basis() + >>> + >>> # Create signal + >>> graph_value = np.copysign(np.ones(np.shape(G.U[:, 3])[0]), G.U[:, 3]) + >>> + >>> G.plot_signal(graph_value, default_qtg=False) This figure shows the original signal on graph. ->>> # Create the mask ->>> M = np.random.rand(G.U.shape[0], 1) ->>> M = M > 0.6 # Probability of having no label on a vertex. ->>> ->>> # Applying the mask to the data ->>> sigma = 0.0 ->>> depleted_graph_value = M * (graph_value.reshape(graph_value.size, 1) + sigma * np.random.standard_normal((G.N, 1))) ->>> ->>> G.plot_signal(depleted_graph_value, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/depleted_signal') +.. plot:: + :context: close-figs -.. figure:: img/depleted_signal.* + >>> # Create the mask + >>> M = np.random.rand(G.U.shape[0], 1) + >>> M = M > 0.6 # Probability of having no label on a vertex. + >>> + >>> # Applying the mask to the data + >>> sigma = 0.0 + >>> depleted_graph_value = M * (graph_value.reshape(graph_value.size, 1) + sigma * np.random.standard_normal((G.N, 1))) + >>> + >>> G.plot_signal(depleted_graph_value, show_edges=True, default_qtg=False) This figure shows the signal on graph after the application of the mask and addition of noise. More than half of the vertices are set to 0. -.. >>> # Setting the function f1 (see pyunlocbox for help) -.. >>> import pyunlocbox -.. >>> import math -.. >>> -.. >>> epsilon = sigma * math.sqrt(np.sum(M[:])) -.. >>> operatorA = lambda x: A * x -.. >>> f1 = pyunlocbox.functions.proj_b2(y=depleted_graph_value, A=operatorA, At=operatorA, tight=True, epsilon=epsilon) -.. >>> -.. >>> # Setting the function ftv -.. >>> f2 = pyunlocbox.functions.func() -.. >>> f2._prox = lambda x, T: operators.prox_tv(x, T, G, verbose=verbose-1) -.. >>> f2._eval = lambda x: operators.norm_tv(G, x) -.. >>> -.. >>> # Solve the problem -.. >>> solver = pyunlocbox.solvers.douglas_rachford() -.. >>> param = {'x0': depleted_graph_value, 'solver': solver, 'atol': 1e-7, 'maxit': 50, 'verbosity': 'LOW'} -.. >>> # With prox_tv -.. >>> ret = pyunlocbox.solvers.solve([f2, f1], **param) -.. >>> prox_tv_reconstructed_graph = ret['sol'] -.. >>> -.. >>> G.plot_signal(prox_tv_reconstructed_graph, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/tv_recons_signal') - -.. figure:: img/tv_recons_signal.* +.. plot:: + :context: close-figs + + >>> # Setting the function f1 (see pyunlocbox for help) + >>> # import pyunlocbox + >>> # import math + >>> + >>> # epsilon = sigma * math.sqrt(np.sum(M[:])) + >>> # operatorA = lambda x: A * x + >>> # f1 = pyunlocbox.functions.proj_b2(y=depleted_graph_value, A=operatorA, At=operatorA, tight=True, epsilon=epsilon) + >>> + >>> # Setting the function ftv + >>> # f2 = pyunlocbox.functions.func() + >>> # f2._prox = lambda x, T: operators.prox_tv(x, T, G, verbose=verbose-1) + >>> # f2._eval = lambda x: operators.norm_tv(G, x) + >>> + >>> # Solve the problem with prox_tv + >>> # ret = pyunlocbox.solvers.solve( + >>> # [f2, f1], + >>> # x0=depleted_graph_value, + >>> # solver=pyunlocbox.solvers.douglas_rachford(), + >>> # atol=1e-7, + >>> # maxit=50, + >>> # verbosity='LOW') + >>> # prox_tv_reconstructed_graph = ret['sol'] + >>> + >>> # G.plot_signal(prox_tv_reconstructed_graph, show_edges=True, default_qtg=False) This figure shows the reconstructed signal thanks to the algorithm. @@ -106,12 +112,19 @@ In this case, we solve: The result is presented as following: -.. >>> # Solve the problem with the same solver as before but with a prox_tik function -.. >>> ret = pyunlocbox.solvers.solve([f3, f1], **param) -.. >>> prox_tik_reconstructed_graph = ret['sol'] -.. >>> -.. >>> G.plot_signal(prox_tik_reconstructed_graph, show_edges=True, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/tik_recons_signal') - -.. figure:: img/tik_recons_signal.* +.. plot:: + :context: close-figs + + >>> # Solve the problem with the same solver as before but with a prox_tik function + >>> # ret = pyunlocbox.solvers.solve( + >>> # [f3, f1], + >>> # x0=depleted_graph_value, + >>> # solver=pyunlocbox.solvers.douglas_rachford(), + >>> # atol=1e-7, + >>> # maxit=50, + >>> # verbosity='LOW') + >>> # prox_tik_reconstructed_graph = ret['sol'] + >>> + >>> # G.plot_signal(prox_tik_reconstructed_graph, show_edges=True, default_qtg=False) This figure shows the reconstructed signal thanks to the algorithm. diff --git a/doc/tutorials/img/.gitignore b/doc/tutorials/img/.gitignore deleted file mode 100644 index 66335542..00000000 --- a/doc/tutorials/img/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.png -*.pdf diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 8b751138..574508b7 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -5,51 +5,62 @@ Introduction to the PyGSP This tutorial shows basic operations of the toolbox. To start open a python shell (IPython is recommended here) and import the pygsp. You would probably also import numpy as you will need it to create matrices and arrays. ->>> import numpy as np ->>> from pygsp import graphs, filters +.. plot:: + :context: reset + + >>> import numpy as np + >>> from pygsp import graphs, filters The first step is to create a graph, there's a general class that can be used to generate graph from it's weight matrix. ->>> np.random.seed(42) # We will use a seed to make reproducible results ->>> W = np.random.rand(400, 400) ->>> G = graphs.Graph(W) +.. plot:: + :context: close-figs + + >>> np.random.seed(42) # We will use a seed to make reproducible results + >>> W = np.random.rand(400, 400) + >>> G = graphs.Graph(W) You have now a graph structure ready to be used everywhere in the box! Check the :mod:`pygsp.graphs` module to know more about the Graph class and it's subclasses. You can also check the included methods for all graphs with the usual help function. For the next steps of the demo, we will be using the logo graph bundled with the toolbox : ->>> G = graphs.Logo() +.. plot:: + :context: close-figs + + >>> G = graphs.Logo() You can now plot the graph: ->>> G.plot(default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo') +.. plot:: + :context: close-figs -.. image:: img/logo.* + >>> G.plot(default_qtg=False) Looks good isn't it? Now we can start to analyse the graph. The next step to compute Graph Fourier Transform or exact graph filtering is to precompute the Fourier basis of the graph. This operation can be very long as it needs to to fully diagonalize the Laplacian. Happily it is not needed to filter signal on graphs. ->>> G.compute_fourier_basis() +.. plot:: + :context: close-figs + + >>> G.compute_fourier_basis() You can now access the eigenvalues of the fourier basis with G.e and the eigenvectors G.U, they look like sinuses on the graph. Let's plot the second and third eigenvectors, as the first is constant. ->>> G.plot_signal(G.U[:, 1], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_second_eigenvector') ->>> G.plot_signal(G.U[:, 2], vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/logo_third_eigenvector') - -.. figure:: img/logo_second_eigenvector.* - - Second eigenvector +.. plot:: + :context: close-figs -.. figure:: img/logo_third_eigenvector.* - - Third eigenvector + >>> G.plot_signal(G.U[:, 1], vertex_size=50, default_qtg=False) + >>> G.plot_signal(G.U[:, 2], vertex_size=50, default_qtg=False) Let's discover basic filters operations, filters are usually defined in the spectral domain. First let's define a filter object: ->>> F = filters.Filter(G) +.. plot:: + :context: close-figs + + >>> F = filters.Filter(G) And we can assign this function @@ -57,38 +68,48 @@ And we can assign this function to it: ->>> tau = 1 ->>> g = lambda x: 1./(1. + tau * x) ->>> F.g = [g] +.. plot:: + :context: close-figs + + >>> tau = 1 + >>> g = lambda x: 1./(1. + tau * x) + >>> F.g = [g] You can also put multiple functions in a list to define a filterbank! ->>> F.plot(plot_eigenvalues=True, savefig=True, plot_name='doc/tutorials/img/low_pass_filter') +.. plot:: + :context: close-figs -.. image:: img/low_pass_filter.* + >>> F.plot(plot_eigenvalues=True) Here's our low pass filter. To go with our new filter, let's create a nice signal on the logo by setting each letter to a certain value and then adding some random noise. ->>> f = np.zeros((G.N,)) ->>> f[G.info['idx_g']-1] = - 1 ->>> f[G.info['idx_s']-1] = 1 ->>> f[G.info['idx_p']-1] = -0.5 ->>> f += np.random.rand(G.N,) +.. plot:: + :context: close-figs + + >>> f = np.zeros((G.N,)) + >>> f[G.info['idx_g']-1] = - 1 + >>> f[G.info['idx_s']-1] = 1 + >>> f[G.info['idx_p']-1] = -0.5 + >>> f += np.random.rand(G.N,) The filter is plotted all along the spectrum of the graph, the cross at the bottom are the laplacian's eigenvalues. Those are the point where the continuous filter will be evaluated to create a discrete filter. To apply it to a given signal, you only need to run: ->>> f2 = F.analysis(f) +.. plot:: + :context: close-figs + + >>> f2 = F.analysis(f) Finally here's the noisy signal and the denoised version right under. ->>> G.plot_signal(f, vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/noisy_logo') ->>> G.plot_signal(f2, vertex_size=50, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/denoised_logo') +.. plot:: + :context: close-figs -.. image:: img/noisy_logo.* -.. image:: img/denoised_logo.* + >>> G.plot_signal(f, vertex_size=50, default_qtg=False) + >>> G.plot_signal(f2, vertex_size=50, default_qtg=False) So here are the basics for the PyGSP toolbox, please check the other tutorials or the reference guide for more. diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index c3f5a48b..c0fa336f 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -10,15 +10,21 @@ The wavelets are a special type of filterbank, in this demo we will show you how In this demo we will show you how to compute the wavelet coefficients of a graph and visualize them. First let's import the toolbox, numpy and load a graph. ->>> import numpy as np ->>> from pygsp import graphs, filters ->>> G = graphs.Bunny() +.. plot:: + :context: reset + + >>> import numpy as np + >>> from pygsp import graphs, filters + >>> G = graphs.Bunny() This graph is a nearest-neighbor graph of a pointcloud of the Stanford bunny. It will allow us to get interesting visual results using wavelets. At this stage we could compute the full Fourier basis using ->>> G.compute_fourier_basis() +.. plot:: + :context: close-figs + + >>> G.compute_fourier_basis() but this would take a lot of time, and can be avoided by using Chebychev polynomials approximations. @@ -27,108 +33,91 @@ Simple filtering Before tackling wavelets, we can see the effect of one filter localized on the graph. So we can first design a few heat kernel filters ->>> taus = [1, 10, 25, 50] ->>> Hk = filters.Heat(G, taus, normalize=False) - -Let's now create a signal as a Kronecker located on one vertex (e.g. the vertex 83) - ->>> S = np.zeros(G.N) ->>> vertex_delta = 83 ->>> S[vertex_delta] = 1 ->>> Sf_vec = Hk.analysis(S) ->>> Sf = Sf_vec.reshape((Sf_vec.size//len(taus), len(taus)), order='F') - -Let's plot the signal: +.. plot:: + :context: close-figs ->>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_1') ->>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_10') ->>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_25') ->>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/heat_tau_50') + >>> taus = [1, 10, 25, 50] + >>> Hk = filters.Heat(G, taus, normalize=False) -.. figure:: img/heat_tau_1.* - :alt: Tau = 1 - :align: center - - Heat tau = 1 - -.. figure:: img/heat_tau_10.* - :alt: Tau = 10 - :align: center +Let's now create a signal as a Kronecker located on one vertex (e.g. the vertex 83) - Heat tau = 10 +.. plot:: + :context: close-figs -.. figure:: img/heat_tau_25.* - :alt: Tau = 25 - :align: center + >>> S = np.zeros(G.N) + >>> vertex_delta = 83 + >>> S[vertex_delta] = 1 + >>> Sf_vec = Hk.analysis(S) + >>> Sf = Sf_vec.reshape((Sf_vec.size//len(taus), len(taus)), order='F') - Heat tau = 25 +Let's plot the signal: -.. figure:: img/heat_tau_50.* - :alt: Tau = 50 - :align: center +.. plot:: + :context: close-figs - Heat tau = 50 + >>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False) Visualizing wavelets atoms -------------------------- Let's now replace the Heat filter by a filter bank of wavelets. We can create a filter bank using one of the predefined filters such as :func:`pygsp.filters.MexicanHat`. ->>> Nf = 6 ->>> Wk = filters.MexicanHat(G, Nf) +.. plot:: + :context: close-figs -We can now plot the filter bank spectrum : + >>> Nf = 6 + >>> Wk = filters.MexicanHat(G, Nf) ->>> Wk.plot(savefig=True, plot_name='doc/tutorials/img/mexican_hat') +We can now plot the filter bank spectrum : -.. figure:: img/mexican_hat.* - :alt: Mexican Hat Wavelet filter - :align: center +.. plot:: + :context: close-figs - Mexican Hat Wavelet filter + >>> Wk.plot() As we can see, the wavelets atoms are stacked on the low frequency part of the spectrum. If we want to get a better coverage of the graph spectrum, we could have used the WarpedTranslates filter bank. ->>> S_vec = Wk.analysis(S) ->>> S = S_vec.reshape((S_vec.size//Nf, Nf), order='F') ->>> G.plot_signal(S[:, 0], default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_filtering') +.. plot:: + :context: close-figs + >>> S_vec = Wk.analysis(S) + >>> S = S_vec.reshape((S_vec.size//Nf, Nf), order='F') + >>> G.plot_signal(S[:, 0], default_qtg=False) We can visualize the filtering by one atom the same way the did for the Heat kernel, by placing a Kronecker delta at one specific vertex. ->>> S = np.zeros((G.N * Nf, Nf)) ->>> S[vertex_delta] = 1 ->>> for i in range(Nf): -... S[vertex_delta + i * G.N, i] = 1 ->>> Sf = Wk.synthesis(S) - ->>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_1') ->>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_2') ->>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_3') ->>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/wavelet_4') - -.. figure:: img/wavelet_1.* -.. figure:: img/wavelet_2.* -.. figure:: img/wavelet_3.* -.. figure:: img/wavelet_4.* - ->>> G = graphs.Bunny() ->>> Wk = filters.MexicanHat(G, Nf) ->>> s_map = G.coords - ->>> s_map_out = Wk.analysis(s_map) ->>> s_map_out = np.reshape(s_map_out, (G.N, Nf, 3)) - ->>> d = s_map_out[:, :, 0]**2 + s_map_out[:, :, 1]**2 + s_map_out[:, :, 2]**2 ->>> d = np.sqrt(d) - ->>> G.plot_signal(d[:, 1], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_1') ->>> G.plot_signal(d[:, 2], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_2') ->>> G.plot_signal(d[:, 3], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_3') ->>> G.plot_signal(d[:, 4], vertex_size=20, default_qtg=False, savefig=True, plot_name='doc/tutorials/img/curv_scale_4') - -.. figure:: img/curv_scale_1.* -.. figure:: img/curv_scale_2.* -.. figure:: img/curv_scale_3.* -.. figure:: img/curv_scale_4.* +.. plot:: + :context: close-figs + + >>> S = np.zeros((G.N * Nf, Nf)) + >>> S[vertex_delta] = 1 + >>> for i in range(Nf): + ... S[vertex_delta + i * G.N, i] = 1 + >>> Sf = Wk.synthesis(S) + >>> + >>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False) + +.. plot:: + :context: close-figs + + >>> G = graphs.Bunny() + >>> Wk = filters.MexicanHat(G, Nf) + >>> s_map = G.coords + >>> + >>> s_map_out = Wk.analysis(s_map) + >>> s_map_out = np.reshape(s_map_out, (G.N, Nf, 3)) + >>> + >>> d = s_map_out[:, :, 0]**2 + s_map_out[:, :, 1]**2 + s_map_out[:, :, 2]**2 + >>> d = np.sqrt(d) + >>> + >>> G.plot_signal(d[:, 1], vertex_size=20, default_qtg=False) + >>> G.plot_signal(d[:, 2], vertex_size=20, default_qtg=False) + >>> G.plot_signal(d[:, 3], vertex_size=20, default_qtg=False) + >>> G.plot_signal(d[:, 4], vertex_size=20, default_qtg=False) From 3f833116cd765fd70843cab39900f361673ec39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 11:43:59 +0200 Subject: [PATCH 120/392] most of unit tests were not run! --- pygsp/tests/test_filters.py | 129 +++++++++++++-------------- pygsp/tests/test_graphs.py | 166 +++++++++++++++++++---------------- pygsp/tests/test_plotting.py | 158 +++++++++++---------------------- pygsp/tests/test_utils.py | 8 +- 4 files changed, 208 insertions(+), 253 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 35874c37..dfb30942 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -1,99 +1,100 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -"""Test suite for the filters module of the pygsp package.""" +""" +Test suite for the filters module of the pygsp package. + +""" import unittest + +import numpy as np + from pygsp import graphs, filters -from numpy import zeros class FunctionsTestCase(unittest.TestCase): def setUp(self): - pass + self._G = graphs.Logo() + self._G.estimate_lmax() def tearDown(self): pass - def test_filters(self): - G = graphs.Logo() - G.estimate_lmax() - - def fu(x): - x / (1. + x) + def _fu(x): + return x / (1. + x) - def test_default_filters(G, fu): - g = filters.Filter(G) - g1 = filters.Filter(G, filters=fu) + def test_default_filters(self): + filters.Filter(self._G) + filters.Filter(self._G, filters=self._fu) - def test_abspline(G): - g = filters.Abspline(G, Nf=4) + def test_abspline(self): + filters.Abspline(self._G, Nf=4) - def test_expwin(G): - g = filters.Expwin(G) + def test_expwin(self): + filters.Expwin(self._G) - def test_gabor(G, fu): - g = filters.Gabor(G, fu) + def test_gabor(self): + filters.Gabor(self._G, self._fu) - def test_halfcosine(G): - g = filters.Halfcosine(G, Nf=4) + def test_halfcosine(self): + filters.HalfCosine(self._G, Nf=4) - def test_heat(G): - g = filters.Heat(G) + def test_heat(self): + filters.Heat(self._G) - def test_held(G): - g = filters.Held(G) - g1 = filters.Held(G, a=0.25) + def test_held(self): + filters.Held(self._G) + filters.Held(self._G, a=0.25) - def test_itersine(G): - g = filters.itersine(G, Nf=4) + def test_itersine(self): + filters.Itersine(self._G, Nf=4) - def test_mexicanhat(G): - g = filters.Mexicanhat(G, Nf=5) - g1 = filters.Mexicanhat(G, Nf=4) + def test_mexicanhat(self): + filters.MexicanHat(self._G, Nf=5) + filters.MexicanHat(self._G, Nf=4) - def test_meyer(G): - g = filters.Meyer(G, Nf=4) + def test_meyer(self): + filters.Meyer(self._G, Nf=4) - def test_papadakis(G): - g = filters.Papadakis(G) - g1 = filters.Papadakis(G, a=0.25) + def test_papadakis(self): + filters.Papadakis(self._G) + filters.Papadakis(self._G, a=0.25) - def test_regular(G): - g = filters.Regular(G) - g1 = filters.Regular(G, d=5) - g2 = filters.Regular(G, d=0) + def test_regular(self): + filters.Regular(self._G) + filters.Regular(self._G, d=5) + filters.Regular(self._G, d=0) - def test_simoncelli(G): - g = filters.Simoncelli(G) - g1 = filters.Simoncelli(G, a=0.25) + def test_simoncelli(self): + filters.Simoncelli(self._G) + filters.Simoncelli(self._G, a=0.25) - def test_simpletf(G): - g = filters.Simpletf(G, Nf=4) + def test_simpletf(self): + filters.SimpleTf(self._G, Nf=4) - # Warped translates are not implemented yet - def test_warpedtranslates(G): - pass - # gw = filters.warpedtranslates(G, g)) + # Warped translates are not implemented yet + def test_warpedtranslates(self): + pass + # gw = filters.warpedtranslates(G, g)) + def test_approximations(self): + r""" + Test that the different methods for filter analysis, i.e. 'exact', + 'cheby', and 'lanczos', produce the same output. """ - Test of the different methods implemented for the filter analysis - Checks if the 'exact', 'cheby' or 'lanczos' produce the same output - of a Heat kernel on the Logo graph - """ - def test_analysis(G): - # Using Kronecker signal at the node 8.3 - S = zeros(G.N) - vertex_delta = 83 - S[vertex_delta] = 1 - g = filters.Heat(G) - c_exact = g.analysis(G, S, method='exact') - c_cheby = g.analysis(G, S, method='cheby') - # c_lancz = g.analysis(G, S, method='lanczos') - self.assertAlmostEqual(c_exact, c_cheby) - # lanczos analysis is not working for now - # self.assertAlmostEqual(c_exact, c_lanczos) + + # Signal is a Kronecker delta at node 83. + s = np.zeros(self._G.N) + s[83] = 1 + + g = filters.Heat(self._G) + c_exact = g.analysis(s, method='exact') + c_cheby = g.analysis(s, method='cheby') + + assert np.allclose(c_exact, c_cheby) + self.assertRaises(NotImplementedError, g.analysis, s, method='lanczos') suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 3a536f50..8e39876d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -1,121 +1,131 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -"""Test suite for the graphs module of the pygsp package.""" +""" +Test suite for the graphs module of the pygsp package. + +""" import unittest + import numpy as np -from scipy import sparse +from skimage import data, img_as_float + from pygsp import graphs class FunctionsTestCase(unittest.TestCase): def setUp(self): - pass + self._img = img_as_float(data.camera()[::16, ::16]) def tearDown(self): pass - def test_graphs(self): + def test_default_graph(self): + W = np.arange(16).reshape(4, 4) + G = graphs.Graph(W) + assert np.allclose(G.W.A, W) + assert np.allclose(G.A.A, G.W.A > 0) + self.assertEqual(G.N, 4) + assert np.allclose(G.d, np.array([[3], [4], [4], [4]])) + self.assertEqual(G.Ne, 15) + self.assertTrue(G.directed) + ki, kj = np.nonzero(G.A) + self.assertEqual(ki.shape[0], G.Ne) + self.assertEqual(kj.shape[0], G.Ne) + + def test_nngraph(self): + Xin = np.arange(90).reshape(30, 3) + dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] + + for dist_type in dist_types: - def test_default_graph(): - W = np.arange(16).reshape(4, 4) - G = graphs.Graph(W) - self.assertEqual(G.W, sparse.lil_matrix(W)) - self.assertEqual(G.A, G.W > 0) - self.assertEqual(G.N, 4) - self.assertEqual(G.d, [3, 4, 4, 4]) - self.assertEqual(G.Ne, 15) - self.assertTrue(G.directed) + # 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) - def test_NNGraph(): - Xin = np.arange(90).reshape(30, 3) - dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] - for dist_type in dist_types: - G1 = graphs.NNGraph(Xin, NNtype='knn', dist_type=dist_type) - G2 = graphs.NNGraph(Xin, use_flann=True, NNtype='knn', - dist_type=dist_type) - G3 = graphs.NNGraph(Xin, NNtype='radius', 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) - def test_Bunny(): - G = graphs.Bunny() + def test_bunny(self): + graphs.Bunny() - def test_Cube(): - G = graphs.Cube() - G2 = graphs.Cube(nb_dim=2) + def test_cube(self): + graphs.Cube() + graphs.Cube(nb_dim=2) - def test_Sphere(): - G = graphs.Sphere() + def test_sphere(self): + graphs.Sphere() - def test_TwoMoons(): - G = graphs.TwoMoons() - G2 = graphs.TwoMoons(moontype='synthetised') + def test_twomoons(self): + graphs.TwoMoons() + graphs.TwoMoons(moontype='synthesized') - def test_Torus(): - G = graphs.Torus() + def test_torus(self): + graphs.Torus() - def test_Comet(): - G = graphs.Comet() + def test_comet(self): + graphs.Comet() - def test_LowStretchTree(): - G = graphs.LowStretchTree() + def test_lowstretchtree(self): + graphs.LowStretchTree() - def test_RandomRegular(): - G = graphs.RandomRegular() + def test_randomregular(self): + graphs.RandomRegular() - def test_Ring(): - G = graphs.Ring() + def test_ring(self): + graphs.Ring() - def test_Community(): - G = graphs.Community() + def test_community(self): + graphs.Community() - def test_Minnesota(): - G = graphs.Minnesota() + def test_minnesota(self): + graphs.Minnesota() - def test_Sensor(): - G = graphs.Sensor() + def test_sensor(self): + graphs.Sensor() - def test_Airfoil(): - G = graphs.Airfoil() + def test_airfoil(self): + graphs.Airfoil() - def test_DavidSensorNet(): - G = graphs.DavidSensorNet() - G2 = graphs.DavidSensorNet(N=500) - G3 = graphs.DavidSensorNet(N=128) + def test_davidsensornet(self): + graphs.DavidSensorNet() + graphs.DavidSensorNet(N=500) + graphs.DavidSensorNet(N=128) - def test_FullConnected(): - G = graphs.FullConnected() + def test_fullconnected(self): + graphs.FullConnected() - def test_Logo(): - G = graphs.Logo() + def test_logo(self): + graphs.Logo() - def test_Path(): - G = graphs.Path() + def test_path(self): + graphs.Path() - def test_RandomRing(): - G = graphs.RandomRing() + def test_randomring(self): + graphs.RandomRing() - def test_SwissRoll(): - G = graphs.SwissRoll() + def test_swissroll(self): + graphs.SwissRoll() - def test_Grid2d(): - G = graphs.Grid2d(shape=(3, 2)) - self.assertEqual([G.h, G.w], [3, 2]) - G = graphs.Grid2d(shape=(3,)) - self.assertEqual([G.h, G.w], [3, 3]) - G = graphs.Grid2d(shape=3) - self.assertEqual([G.h, G.w], [3, 3]) + def test_grid2d(self): + G = graphs.Grid2d(shape=(3, 2)) + self.assertEqual([G.h, G.w], [3, 2]) + G = graphs.Grid2d(shape=(3,)) + self.assertEqual([G.h, G.w], [3, 3]) + G = graphs.Grid2d(shape=3) + self.assertEqual([G.h, G.w], [3, 3]) - def test_ImgPatches(): - from skimage import data, img_as_float - img = img_as_float(data.camera()[::16, ::16]) - G = graphs.ImgPatches(img=img, patch_shape=(3, 3)) + def test_imgpatches(self): + graphs.ImgPatches(img=self._img, patch_shape=(3, 3)) - def test_Grid2dImgPatches(): - from skimage import data, img_as_float - img = img_as_float(data.camera()[::16, ::16]) - G = graphs.Grid2dImgPatches(img=img, patch_shape=(3, 3)) + def test_grid2dimgpatches(self): + graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index a3e642d4..0485ad6a 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -1,131 +1,71 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -"""Test suite for the plotting module of the pygsp package.""" +""" +Test suite for the plotting module of the pygsp package. + +""" import unittest + import numpy as np +from skimage import data, img_as_float + from pygsp import graphs class FunctionsTestCase(unittest.TestCase): def setUp(self): - pass + self._img = img_as_float(data.camera()[::16, ::16]) def tearDown(self): pass - def test_plotting(self): - - def needed_attributes_testing(G): + def test_is_plottable(self): + + classnames = graphs.__all__ + + # Graphs who are not embedded. + classnames.remove('Graph') + classnames.remove('BarabasiAlbert') + classnames.remove('ErdosRenyi') + classnames.remove('FullConnected') + classnames.remove('RandomRegular') + classnames.remove('RandomRing') + classnames.remove('Ring') # TODO: should have! + classnames.remove('StochasticBlockModel') + + Gs = [] + for classname in classnames: + Graph = getattr(graphs, classname) + + # Classes who require parameters. + if classname == 'NNGraph': + Xin = np.arange(90).reshape(30, 3) + Gs.append(Graph(Xin)) + elif classname in ['ImgPatches', 'Grid2dImgPatches']: + Gs.append(Graph(img=self._img, patch_shape=(3, 3))) + else: + Gs.append(Graph()) + + # Add more test cases. + if classname == 'TwoMoons': + Gs.append(Graph(moontype='standard')) + Gs.append(Graph(moontype='synthesized')) + elif classname == 'Cube': + Gs.append(Graph(nb_dim=2)) + Gs.append(Graph(nb_dim=3)) + elif classname == 'DavidSensorNet': + Gs.append(Graph(N=64)) + Gs.append(Graph(N=500)) + Gs.append(Graph(N=128)) + + for G in Gs: + # Check attributes. self.assertTrue(hasattr(G, 'coords')) self.assertTrue(hasattr(G, 'A')) self.assertEqual(G.N, G.coords.shape[0]) - def test_default_graph(): - W = np.arange(16).reshape(4, 4) - G = graphs.Graph(W) - ki, kj = np.nonzero(G.A) - self.assertEqual(ki.shape[0], G.Ne) - self.assertEqual(kj.shape[0], G.Ne) - needed_attributes_testing(G) - - def test_NNGraph(): - Xin = np.arange(90).reshape(30, 3) - G = graphs.NNGraph(Xin) - needed_attributes_testing(G) - - def test_Bunny(): - G = graphs.Bunny() - needed_attributes_testing(G) - - def test_Cube(): - G = graphs.Cube() - G2 = graphs.Cube(nb_dim=2) - needed_attributes_testing(G) - - needed_attributes_testing(G2) - - def test_Sphere(): - G = graphs.Sphere() - needed_attributes_testing(G) - - def test_TwoMoons(): - G = graphs.TwoMoons() - G2 = graphs.TwoMoons(moontype='synthetised') - needed_attributes_testing(G) - - needed_attributes_testing(G2) - - def test_Grid2d(): - G = graphs.Grid2d() - needed_attributes_testing(G) - - def test_Torus(): - G = graphs.Torus() - needed_attributes_testing(G) - - def test_Comet(): - G = graphs.Comet() - needed_attributes_testing(G) - - def test_LowStretchTree(): - G = graphs.LowStretchTree() - needed_attributes_testing(G) - - def test_RandomRegular(): - G = graphs.RandomRegular() - needed_attributes_testing(G) - - def test_Ring(): - G = graphs.Ring() - needed_attributes_testing(G) - - def test_Community(): - G = graphs.Community() - needed_attributes_testing(G) - - def test_Minnesota(): - G = graphs.Minnesota() - needed_attributes_testing(G) - - def test_Sensor(): - G = graphs.Sensor() - needed_attributes_testing(G) - - def test_Airfoil(): - G = graphs.Airfoil() - needed_attributes_testing(G) - - def test_DavidSensorNet(): - G = graphs.DavidSensorNet() - G2 = graphs.DavidSensorNet(N=500) - G3 = graphs.DavidSensorNet(N=128) - - needed_attributes_testing(G) - needed_attributes_testing(G2) - needed_attributes_testing(G3) - - def test_FullConnected(): - G = graphs.FullConnected() - needed_attributes_testing(G) - - def test_Logo(): - G = graphs.Logo() - needed_attributes_testing(G) - - def test_Path(): - G = graphs.Path() - needed_attributes_testing(G) - - def test_RandomRing(): - G = graphs.RandomRing() - needed_attributes_testing(G) - - def test_SwissRoll(): - G = graphs.SwissRoll() - needed_attributes_testing(G) - suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 7fcf9c1c..62891c01 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -3,12 +3,15 @@ """ Test suite for the utils module of the pygsp package. + """ import unittest + import numpy as np from scipy import sparse -from pygsp import utils, graphs, operators + +from pygsp import utils, graphs class FunctionsTestCase(unittest.TestCase): @@ -22,7 +25,8 @@ def tearDown(self): 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]]) + 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) From 98baa4a63bcb37d3ae13438afdc252a6734d12eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 11:44:44 +0200 Subject: [PATCH 121/392] wording --- pygsp/graphs/nngraphs/nngraph.py | 2 +- pygsp/graphs/nngraphs/twomoons.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index ff245a94..3d9b40d7 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -157,7 +157,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, start = start + leng else: - raise ValueError('Unknown type : allowed values are knn, radius') + raise ValueError('Unknown NNtype {}'.format(self.NNtype)) W = csc_matrix((spv, (spi, spj)), shape=(N, N)) diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 969a66d6..16424ca2 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -83,6 +83,9 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, Xin = np.concatenate((coords1, coords2)) + else: + raise ValueError('Unknown moontype {}'.format(moontype)) + self.labels = np.concatenate((np.zeros(N1), np.ones(N2))) super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, gtype=gtype) From 30430d276864b7359c3ff6d49f0e936fb87ede15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 13:00:09 +0200 Subject: [PATCH 122/392] more comprehensive tests for graphs --- pygsp/graphs/community.py | 4 ++-- pygsp/graphs/erdosrenyi.py | 20 +++++++++----------- pygsp/graphs/ring.py | 4 ++-- pygsp/graphs/sensor.py | 8 +++++--- pygsp/tests/test_graphs.py | 22 ++++++++++++++++++++-- 5 files changed, 38 insertions(+), 20 deletions(-) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index e0cadc13..9204f1ee 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -133,7 +133,7 @@ def __init__(self, N=256, **kwargs): if comm_density: nb_edges = int(comm_density * M) tril_ind = np.tril_indices(com_siz, -1) - indices = np.random.permutation(M)[:nb_edges] + indices = np.random.permutation(int(M))[:nb_edges] w_data[0] += [1] * nb_edges w_data[1][0] += [first_node + tril_ind[1][elem] for elem in indices] @@ -175,7 +175,7 @@ def __init__(self, N=256, **kwargs): inter_edges.add((min(new_point), max(new_point))) else: # use random permutation - indices = np.random.permutation(M)[:nb_edges] + indices = np.random.permutation(int(M))[:nb_edges] all_points, first_col = [], 0 for i in range(Nc - 1): nb_col = info['comm_sizes'][i] diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 26daf044..ecd1c6f0 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -21,11 +21,12 @@ class ErdosRenyi(Graph): Number of nodes (default is 100) p : float Probability of connection of a node with another - param : - Structure of optional parameter - connected - flag to force the graph to be connected. By default, it is False. - directed - define if the graph is directed. By default, it is False. - max_iter - is the maximum number of try to connect the graph. By default, it is 10. + connected : bool + Force the graph to be connected (default is False). + directed : bool + Define if the graph is directed (default is False). + max_iter : int + Maximum number of try to connect the graph (default is 10). Examples -------- @@ -34,13 +35,10 @@ class ErdosRenyi(Graph): """ - def __init__(self, N=100, p=0.1, **kwargs): + def __init__(self, N=100, p=0.1, connected=False, directed=False, + max_iter=10, **kwargs): self.p = p - need_connected = bool(kwargs.pop('connected', False)) - directed = bool(kwargs.pop('directed', False)) - max_iter = int(kwargs.pop('max_iter', 10)) - if p > 1: raise ValueError("GSP_ErdosRenyi: The probability p " "cannot be above 1.") @@ -66,7 +64,7 @@ def __init__(self, N=100, p=0.1, **kwargs): self.A = self.W > 0 is_connected = self.is_connected() - if not need_connected or is_connected: + if not connected or is_connected: break super(ErdosRenyi, self).__init__(W=self.W, gtype=u"Erdös Renyi", **kwargs) diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 536c8dd1..af3309bf 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -31,7 +31,7 @@ def __init__(self, N=64, k=1, **kwargs): # Create weighted adjacency matrix if 2*k == N: - num_edges = N * (k - 1) + N / 2. + num_edges = N * (k - 1) + k else: num_edges = N * k @@ -45,7 +45,7 @@ def __init__(self, N=64, k=1, **kwargs): i_inds[(2*i + 1)*N + tmpN] = np.remainder(tmpN + i + 1, N) j_inds[(2*i + 1)*N + tmpN] = tmpN - if k == N/2.: + if 2*k == N: i_inds[2*N*(k - 1) + tmpN] = tmpN i_inds[2*N*(k - 1) + tmpN] = np.remainder(tmpN + k + 1, N) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 6737573a..fa5da1df 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -4,7 +4,7 @@ from scipy.sparse import lil_matrix, csc_matrix from . import Graph -from ..utils import distanz +from ..utils import build_logger, distanz class Sensor(Graph): @@ -41,6 +41,8 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, self.n_try = n_try self.distribute = distribute + self.logger = build_logger(__name__, **kwargs) + if connected: for x in range(self.n_try): W, coords = self._create_weight_matrix(N, distribute, @@ -82,7 +84,7 @@ def _get_nc_connection(self, W, param_nc): return W - def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): + def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): XCoords = np.zeros((N, 1)) YCoords = np.zeros((N, 1)) @@ -109,7 +111,7 @@ def _create_weight_matrix(self, N, param_distribute, param_regular, param_Nc): W = np.exp(-d**2/(2.*s**2)) W -= np.diag(np.diag(W)) - if param_regular: + if regular: W = self._get_nc_connection(W, param_Nc) else: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8e39876d..737b6244 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -80,15 +80,24 @@ def test_randomregular(self): def test_ring(self): graphs.Ring() + graphs.Ring(N=32, k=16) def test_community(self): graphs.Community() + graphs.Community(comm_density=0.2) + graphs.Community(k_neigh=5) + graphs.Community(world_density=0.8) def test_minnesota(self): graphs.Minnesota() def test_sensor(self): - graphs.Sensor() + graphs.Sensor(regular=True) + graphs.Sensor(regular=False) + graphs.Sensor(distribute=True) + graphs.Sensor(distribute=False) + graphs.Sensor(connected=True) + graphs.Sensor(connected=False) def test_airfoil(self): graphs.Airfoil() @@ -98,6 +107,10 @@ def test_davidsensornet(self): graphs.DavidSensorNet(N=500) graphs.DavidSensorNet(N=128) + def test_erdosreny(self): + graphs.ErdosRenyi(connected=False) + graphs.ErdosRenyi(connected=True) + def test_fullconnected(self): graphs.FullConnected() @@ -111,7 +124,12 @@ def test_randomring(self): graphs.RandomRing() def test_swissroll(self): - graphs.SwissRoll() + graphs.SwissRoll(srtype='uniform') + graphs.SwissRoll(srtype='classic') + graphs.SwissRoll(noise=True) + graphs.SwissRoll(noise=False) + graphs.SwissRoll(dim=2) + graphs.SwissRoll(dim=3) def test_grid2d(self): G = graphs.Grid2d(shape=(3, 2)) From 012b8d1184db5c912d0bc4d84afe2ad81b21a8d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 15:11:05 +0200 Subject: [PATCH 123/392] more comprehensive tests for graphs --- pygsp/graphs/erdosrenyi.py | 3 +-- pygsp/graphs/graph.py | 9 ++++----- pygsp/graphs/stochasticblockmodel.py | 2 +- pygsp/tests/test_graphs.py | 24 ++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index ecd1c6f0..7d0713f6 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -4,14 +4,13 @@ from scipy.sparse import csr_matrix from . import Graph -from ..utils import build_logger class ErdosRenyi(Graph): r""" Create a random Erdos Renyi graph. - The Erdos Renyi graph is constructed by connecting nodes randomly. Each + The Erdos Renyi graph is constructed by connecting nodes randomly. Each edge is included in the graph with probability p independent from every other edge. All edge weights are equal to 1. diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e211aa4f..3328732a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -658,14 +658,13 @@ def create_laplacian(self, lap_type='combinatorial'): implemented for directed graphs. """ - if np.shape(self.W) == (1, 1): + if self.W.shape == (1, 1): self.L = sparse.lil_matrix(0) return - if lap_type in ['combinatorial', 'normalized', 'none']: - self.lap_type = lap_type - else: - raise AttributeError('Unknown laplacian type!') + if lap_type not in ['combinatorial', 'normalized', 'none']: + raise AttributeError('Unknown laplacian type {}'.format(lap_type)) + self.lap_type = lap_type if self.directed: if lap_type == 'combinatorial': diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index f5ff9413..e7beb1bf 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -99,7 +99,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if not no_self_loop: # avoid doubling the self loops with the above sum - W[np.arange(N), np.arange(N)] /= 2. + W[range(N), range(N)] = (W.diagonal() == 2) self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 737b6244..972d00b5 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -35,6 +35,22 @@ def test_default_graph(self): self.assertEqual(ki.shape[0], G.Ne) self.assertEqual(kj.shape[0], G.Ne) + def test_laplacian(self): + # TODO: should test correctness. + + G = graphs.StochasticBlockModel(undirected=True) + self.assertFalse(G.is_directed()) + G.create_laplacian(lap_type='combinatorial') + G.create_laplacian(lap_type='normalized') + G.create_laplacian(lap_type='none') + + G = graphs.StochasticBlockModel(undirected=False) + self.assertTrue(G.is_directed()) + G.create_laplacian(lap_type='combinatorial') + G.create_laplacian(lap_type='none') + self.assertRaises(NotImplementedError, G.create_laplacian, + lap_type='normalized') + def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] @@ -99,6 +115,12 @@ def test_sensor(self): graphs.Sensor(connected=True) graphs.Sensor(connected=False) + def test_stochasticblockmodel(self): + graphs.StochasticBlockModel(undirected=True) + graphs.StochasticBlockModel(undirected=False) + graphs.StochasticBlockModel(no_self_loop=True) + graphs.StochasticBlockModel(no_self_loop=False) + def test_airfoil(self): graphs.Airfoil() @@ -110,6 +132,8 @@ def test_davidsensornet(self): def test_erdosreny(self): graphs.ErdosRenyi(connected=False) graphs.ErdosRenyi(connected=True) + graphs.ErdosRenyi(directed=False) + # graphs.ErdosRenyi(directed=True) # TODO: bug in implementation def test_fullconnected(self): graphs.FullConnected() From 61316eb8e96fcd975f1e93da6d69f6426a3b3caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 16:19:56 +0200 Subject: [PATCH 124/392] correct handling of caching and recomputing impact is_directed, is_connected, compute_fourier_basis and estimate_lmax --- pygsp/data_handling.py | 5 +- pygsp/graphs/erdosrenyi.py | 3 +- pygsp/graphs/graph.py | 146 +++++++++++++++-------------------- pygsp/graphs/sensor.py | 2 +- pygsp/operators/reduction.py | 4 +- pygsp/plotting.py | 32 ++------ pygsp/tests/test_graphs.py | 2 +- pygsp/tests/test_utils.py | 2 +- 8 files changed, 79 insertions(+), 117 deletions(-) diff --git a/pygsp/data_handling.py b/pygsp/data_handling.py index 8a3a1f88..7f8bee4f 100644 --- a/pygsp/data_handling.py +++ b/pygsp/data_handling.py @@ -17,10 +17,7 @@ def adj2vec(G): G : Graph structure """ - if not hasattr(G, 'directed'): - G.is_directed() - - if G.directed: + if G.is_directed(): raise NotImplementedError("Not implemented yet.") else: diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 7d0713f6..9eaa8592 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -61,9 +61,8 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, matrix = csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) self.W = matrix if directed else matrix + matrix.T self.A = self.W > 0 - is_connected = self.is_connected() - if not connected or is_connected: + if not connected or self.is_connected(recompute=True): break super(ErdosRenyi, self).__init__(W=self.W, gtype=u"Erdös Renyi", **kwargs) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 3328732a..545ab113 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -29,6 +29,8 @@ class Graph(object): vertices coordinates (default is None) plotting : dict plotting parameters + perform_checks : bool + Whether to check if the graph is connected. Warn if not. Attributes ---------- @@ -53,10 +55,6 @@ class Graph(object): gtype : string the graph type is a short description of the graph object designed to help sorting the graphs. - directed : bool - indicates if the graph is directed or not. - In this framework, we consider that a graph is directed if and - only if its weight matrix is non symmetric. L : sparse matrix or ndarray the graph Laplacian, an N-by-N matrix computed from W. lap_type : 'none', 'normalized', 'combinatorial' @@ -78,7 +76,7 @@ class Graph(object): """ def __init__(self, W, gtype='unknown', lap_type='combinatorial', - coords=None, plotting={}, perform_all_checks=True, **kwargs): + coords=None, plotting={}, perform_checks=True, **kwargs): self.logger = build_logger(__name__, **kwargs) @@ -95,18 +93,12 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.gtype = gtype self.lap_type = lap_type - # (Rodrigo): This check is only inside self.is_connected(), but I - # think they should be independent of each other. - if not hasattr(self, 'directed'): - self.is_directed() - - # (Rodrigo): I don't think this should be called by default when we - # create the graph. It is very expensive for big graphs. For now I kept - # the default behavior as is, but added a flag that allows the user to - # turn this off. - if perform_all_checks: - self.is_connected() - if not self.connected: + if coords is not None: + self.coords = coords + + # Very expensive for big graphs. Allow user to opt out. + if perform_checks: + if not self.is_connected(): self.logger.warning('Graph is not connected!') self.create_laplacian(lap_type) @@ -400,9 +392,9 @@ def subgraph(self, ind): sub_W = self.W.tocsr()[ind, :].tocsc()[:, ind] return Graph(sub_W, gtype="sub-{}".format(self.gtype)) - def is_connected(self, force_recompute=False): + def is_connected(self, recompute=False): r""" - Check the strong connectivity of the input graph. + Check the strong connectivity of the input graph. Result is 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 @@ -414,13 +406,13 @@ def is_connected(self, force_recompute=False): Parameters ---------- - force_recompute: bool + recompute: bool Force to recompute the connectivity if already known. Returns ------- connected : bool - A bool value telling if the graph is connected. + True if the graph is connected. Examples -------- @@ -431,25 +423,21 @@ def is_connected(self, force_recompute=False): >>> connected = G.is_connected() """ - if hasattr(self, 'force_recompute'): - if force_recompute: - self.logger.warning("Connectivity for this graph is already " - "known. Recomputing.") - else: - self.logger.error("Connectivity for this graph is already " - "known. Stopping.") - return self.connected - - if not hasattr(self, 'directed'): - self.is_directed() + if hasattr(self, '_connected') and not recompute: + return self._connected if self.A.shape[0] != self.A.shape[1]: self.logger.error("Inconsistent shape to test connectedness. " "Set to False.") - self.connected = False - return False + self._connected = False + return self._connected - for adj_matrix in [self.A, self.A.T] if self.directed else [self.A]: + if self.is_directed(recompute=recompute): + adj_matrices = [self.A, self.A.T] + else: + adj_matrices = [self.A] + + for adj_matrix in adj_matrices: visited = np.zeros(self.A.shape[0], dtype=bool) stack = set([0]) @@ -465,21 +453,29 @@ def is_connected(self, force_recompute=False): if not visited[idx]])) if not visited.all(): - self.connected = False - return False + self._connected = False + return self._connected - self.connected = True - return True + self._connected = True + return self._connected - def is_directed(self, force_recompute=False): + def is_directed(self, recompute=False): r""" - Define if the graph has directed edges. + Check if the graph has directed edges. Result is cached. + + In this framework, we consider that a graph is directed if and + only if its weight matrix is non symmetric. Parameters ---------- - force_recompute: bool + recompute : bool Force to recompute the directedness if already known. + Returns + ------- + directed : bool + True if the graph is directed. + Notes ----- Can also be used to check if a matrix is symmetrical @@ -493,24 +489,15 @@ def is_directed(self, force_recompute=False): >>> directed = G.is_directed() """ - if hasattr(self, 'force_recompute'): - if force_recompute: - self.logger.warning("Directedness for this graph is already " - "known. Recomputing.") - else: - self.logger.error("Directedness for this graph is already " - "known. Stopping.") - return self.directed + if hasattr(self, '_directed') and not recompute: + return self._directed if np.diff(np.shape(self.W))[0]: raise ValueError("Matrix dimensions mismatch, expecting square " "matrix.") - is_dir = np.abs(self.W - self.W.T).sum() != 0 - - self.directed = is_dir - - return is_dir + self._directed = np.abs(self.W - self.W.T).sum() != 0 + return self._directed def extract_components(self): r""" @@ -539,15 +526,12 @@ def extract_components(self): >>> sinks_0 = components[0].info['sink'] if has_sinks else [] """ - if not hasattr(self, 'directed'): - self.is_directed() - if self.A.shape[0] != self.A.shape[1]: self.logger.error('Inconsistent shape to extract components. ' 'Square matrix required.') return None - if self.directed: + if self.is_directed(): raise NotImplementedError('Focusing on undirected graphs first.') graphs = [] @@ -579,7 +563,7 @@ def extract_components(self): return graphs - def compute_fourier_basis(self, smallest_first=True, force_recompute=False, + def compute_fourier_basis(self, smallest_first=True, recompute=False, **kwargs): r""" Compute the fourier basis of the graph. @@ -589,7 +573,7 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, smallest_first: bool Define the order of the eigenvalues. Default is smallest first (True). - force_recompute: bool + recompute: bool Force to recompute the Fourier basis if already existing. Notes @@ -619,21 +603,15 @@ def compute_fourier_basis(self, smallest_first=True, force_recompute=False, See :cite:`chung1997spectral` """ - if hasattr(self, 'e') or hasattr(self, 'U'): - if force_recompute: - self.logger.warning("This graph already has a Fourier basis." - " Recomputing.") - else: - self.logger.error("This graph already has a Fourier basis. " - "Stopping.") - return + if hasattr(self, 'e') and hasattr(self, 'U') and not recompute: + return if self.N > 3000: self.logger.warning("Performing full eigendecomposition of a " "large matrix may take some time.") if not hasattr(self, 'L'): - raise AttributeError("Graph Laplacian is missing") + raise AttributeError("Graph Laplacian is missing.") eigenvectors, eigenvalues, _ = svd(self.L.todense()) @@ -666,7 +644,7 @@ def create_laplacian(self, lap_type='combinatorial'): raise AttributeError('Unknown laplacian type {}'.format(lap_type)) self.lap_type = lap_type - if self.directed: + if self.is_directed(): if lap_type == 'combinatorial': L = 0.5 * (sparse.diags(np.ravel(self.W.sum(0)), 0) + sparse.diags(np.ravel(self.W.sum(1)), 0) - @@ -688,32 +666,35 @@ def create_laplacian(self, lap_type='combinatorial'): self.L = L - def estimate_lmax(self, force_recompute=False): + def estimate_lmax(self, recompute=False): r""" Estimate the maximal eigenvalue. + Exact value given by the eigendecomposition of the Laplacia, see + :func:`compute_fourier_basis`. + Parameters ---------- - force_recompute : boolean + recompute : boolean Force to recompute the maximal eigenvalue. Default is false. + Returns + ------- + lmax : float + An estimation of the largest eigenvalue. + Examples -------- - Just define a graph and apply the estimation on it. - >>> from pygsp import graphs >>> import numpy as np >>> W = np.arange(16).reshape(4, 4) >>> G = graphs.Graph(W) - >>> G.estimate_lmax() + >>> print('{:.2f}'.format(G.estimate_lmax())) + 41.59 """ - if hasattr(self, 'lmax'): - if force_recompute: - self.logger.error('Already computed lmax. Recomputing.') - else: - self.logger.error('Already computed lmax. Stopping.') - return + if hasattr(self, 'lmax') and not recompute: + return self.lmax try: # For robustness purposes, increase the error by 1 percent @@ -727,6 +708,7 @@ def estimate_lmax(self, force_recompute=False): lmax = np.real(lmax) self.lmax = lmax.sum() + return self.lmax def plot(self, **kwargs): r""" diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index fa5da1df..c3a9aff7 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -50,7 +50,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, self.W = W self.A = W > 0 - if self.is_connected(): + if self.is_connected(recompute=True): break elif x == self.n_try - 1: diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index bd5258ab..0da93c19 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -111,7 +111,7 @@ def graph_sparsify(M, epsilon, maxiter=10): if isinstance(M, Graph): sparserW = sparse.diags(sparserL.diagonal(), 0) - sparserL - if not M.directed: + if not M.is_directed(): sparserW = (sparserW + sparserW.T) / 2. Mnew = Graph(W=sparserW) @@ -311,7 +311,7 @@ def kron_reduction(G, ind): message = 'Unknwon reduction for {} laplacian.'.format(G.lap_type) raise NotImplementedError(message) - if G.directed: + if G.is_directed(): message = 'This method only work for undirected graphs.' raise NotImplementedError(message) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index e27e0b61..0461a50f 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -166,12 +166,8 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name if show_edges: ki, kj = np.nonzero(G.A) - if G.directed: - raise NotImplementedError('TODO') - if G.coords.shape[1] == 2: - raise NotImplementedError('TODO') - else: - raise NotImplementedError('TODO') + if G.is_directed(): + raise NotImplementedError else: if G.coords.shape[1] == 2: ki, kj = np.nonzero(G.A) @@ -259,12 +255,8 @@ def _pg_plot_graph(G, show_edges=None, plot_name=''): show_edges = G.Ne < 10000 ki, kj = np.nonzero(G.A) - if G.directed: - raise NotImplementedError('TODO') - if G.coords.shape[1] == 2: - raise NotImplementedError('TODO') - else: - raise NotImplementedError('TODO') + if G.is_directed(): + raise NotImplementedError else: if G.coords.shape[1] == 2: adj = np.concatenate((np.expand_dims(ki, axis=1), @@ -531,12 +523,8 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], if show_edges: ki, kj = np.nonzero(G.A) - if G.directed: - raise NotImplementedError('TODO') - if G.coords.shape[1] == 2: - raise NotImplementedError('TODO') - else: - raise NotImplementedError('TODO') + if G.is_directed(): + raise NotImplementedError else: if G.coords.shape[1] == 2: @@ -621,12 +609,8 @@ def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], # Plot edges if show_edges: ki, kj = np.nonzero(G.A) - if G.directed: - raise NotImplementedError('TODO') - if G.coords.shape[1] == 2: - raise NotImplementedError('TODO') - else: - raise NotImplementedError('TODO') + if G.is_directed(): + raise NotImplementedError else: if G.coords.shape[1] == 2: adj = np.concatenate((np.expand_dims(ki, axis=1), diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 972d00b5..88f1afec 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -30,7 +30,7 @@ def test_default_graph(self): self.assertEqual(G.N, 4) assert np.allclose(G.d, np.array([[3], [4], [4], [4]])) self.assertEqual(G.Ne, 15) - self.assertTrue(G.directed) + 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) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 62891c01..0a5c1765 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -80,7 +80,7 @@ def test_utils(self): test_graphs = [t1, t3, t4] def test_is_directed(G, is_dir): - self.assertEqual(G.directed, is_dir) + self.assertEqual(G.is_directed(), is_dir) def test_laplacian(G, lap): self.assertTrue((G.L == lap).all()) From 812b614207ed509430079351340466ec15a91f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 16:37:01 +0200 Subject: [PATCH 125/392] plotting: graph has coords attribute only if there is coords --- pygsp/graphs/graph.py | 5 ----- pygsp/plotting.py | 18 +++++++++--------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 545ab113..5da28304 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -103,11 +103,6 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.create_laplacian(lap_type) - if isinstance(coords, np.ndarray) and 2 <= len(np.shape(coords)) <= 3: - self.coords = coords - else: - self.coords = np.ndarray(None) - self.plotting = {'vertex_size': 10, 'edge_width': 1, 'edge_style': '-', 'vertex_color': 'b'} self.plotting.update(plotting) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 0461a50f..0ae08f93 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -16,7 +16,6 @@ try: import matplotlib.pyplot as plt - from mpl_toolkits.mplot3d import Axes3D plt_import = True except Exception as e: print('ERROR : Could not import packages for matplotlib.') @@ -25,7 +24,7 @@ try: import pyqtgraph as pg - from pyqtgraph.Qt import QtCore, QtGui + from pyqtgraph.Qt import QtGui import pyqtgraph.opengl as gl qtg_import = True except Exception as e: @@ -129,6 +128,10 @@ def plot_graph(G, default_qtg=True, **kwargs): >>> plotting.plot_graph(G, default_qtg=False) """ + if not hasattr(G, 'coords'): + raise AttributeError('Graph has no coordinate set. ' + 'Please run G.set_coords() first.') + if qtg_import and (default_qtg or not plt_import): _pg_plot_graph(G, **kwargs) elif plt_import and not (default_qtg and qtg_import): @@ -155,9 +158,6 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name if 'edge_color' not in G.plotting: G.plotting['edge_color'] = np.array([255, 88, 41])/255. - if not hasattr(G, 'coords'): - raise AttributeError('G has no coordinate set. Please run G.set_coords() first.') - # Matplotlib graph initialization in 2D and 3D if G.coords.shape[1] == 2: ax = fig.add_subplot(111) @@ -247,10 +247,6 @@ def _pg_plot_graph(G, show_edges=None, plot_name=''): if 'window_list' not in globals(): window_list = {} - if not G.coords.shape: - raise AttributeError('G has no coordinate set. Please run G.set_coords() first.') - - if show_edges is None: show_edges = G.Ne < 10000 @@ -483,6 +479,10 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): >>> plotting.plot_signal(G, signal, default_qtg=False) """ + if not hasattr(G, 'coords'): + raise AttributeError('Graph has no coordinate set. ' + 'Please run G.set_coords() first.') + if qtg_import and (default_qtg or not plt_import): _pg_plot_signal(G, signal, **kwargs) elif plt_import and not (default_qtg and qtg_import): From 6f3cf158734807a2270a14ed6c14aea11e9b4223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 20:18:20 +0200 Subject: [PATCH 126/392] graphs.Cube: wrong coordinates in 2D --- pygsp/graphs/nngraphs/cube.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index a4f14cd9..132b90e3 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -39,7 +39,7 @@ def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling="random", **kwargs): if self.sampling == "random": if self.nb_dim == 2: - pts = np.random.rand(self.nb_pts, self.nb_pts) + pts = np.random.rand(self.nb_pts, self.nb_dim) elif self.nb_dim == 3: n = self.nb_pts // 6 From 7a96f3dcb09d2f87ead6b5d0045d015f83d8838b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 20:21:40 +0200 Subject: [PATCH 127/392] plotting: close_all(), style, bug fixes --- pygsp/plotting.py | 214 ++++++++++++++++++++++++++-------------------- 1 file changed, 121 insertions(+), 93 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 0ae08f93..d24968fa 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -12,10 +12,11 @@ """ import numpy as np -import uuid try: import matplotlib.pyplot as plt + # Not used directly, but needed for 3D projection. + from mpl_toolkits.mplot3d import Axes3D # noqa plt_import = True except Exception as e: print('ERROR : Could not import packages for matplotlib.') @@ -23,7 +24,7 @@ plt_import = False try: - import pyqtgraph as pg + import pyqtgraph as qtg from pyqtgraph.Qt import QtGui import pyqtgraph.opengl as gl qtg_import = True @@ -33,14 +34,34 @@ qtg_import = False -class plid(): - r"""Not so clean way of generating plot_ids.""" +_qtg_windows = [] +_qtg_applications = [] +_plt_figures = [] - def __init__(self): - self.plot_id = 0 +def close_all(): + r""" + Close all opened windows. + + """ + + # Windows can be closed by releasing all references to them so they can be + # garbage collected. Not necessary to call close(). + global _qtg_windows + for window in _qtg_windows: + window.close() + _qtg_windows = [] + + # Segmentation faults when executing test_plotting. + # global _qtg_applications + # for application in _qtg_applications: + # application.quit() + # _qtg_applications = [] -plid = plid() + global _plt_figures + for fig in _plt_figures: + plt.close(fig) + _plt_figures = [] def show(block=False, **kwargs): @@ -131,9 +152,11 @@ def plot_graph(G, default_qtg=True, **kwargs): if not hasattr(G, 'coords'): raise AttributeError('Graph has no coordinate set. ' 'Please run G.set_coords() first.') + if G.coords.shape[1] not in [2, 3]: + raise AttributeError('Coordinates should be in 2 or 3D space.') if qtg_import and (default_qtg or not plt_import): - _pg_plot_graph(G, **kwargs) + _qtg_plot_graph(G, **kwargs) elif plt_import and not (default_qtg and qtg_import): _plt_plot_graph(G, **kwargs) else: @@ -146,8 +169,9 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) if not plot_name: plot_name = u"Plot of {}".format(G.gtype) @@ -155,8 +179,15 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name if show_edges is None: show_edges = G.Ne < 10000 - if 'edge_color' not in G.plotting: - G.plotting['edge_color'] = np.array([255, 88, 41])/255. + try: + vertex_size = G.plotting['vertex_size'] + except KeyError: + vertex_size = 100 + + try: + edge_color = G.plotting['edge_color'] + except KeyError: + edge_color = np.array([255, 88, 41]) / 255. # Matplotlib graph initialization in 2D and 3D if G.coords.shape[1] == 2: @@ -178,18 +209,18 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name if isinstance(G.plotting['vertex_color'], list): ax.plot(x, y, linewidth=G.plotting['edge_width'], - color=G.plotting['edge_color'], + color=edge_color, linestyle=G.plotting['edge_style'], marker='', zorder=1) ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', - s=G.plotting['vertex_size'], + s=vertex_size, c=G.plotting['vertex_color'], zorder=2) else: ax.plot(x, y, linewidth=G.plotting['edge_width'], - color=G.plotting['edge_color'], + color=edge_color, linestyle=G.plotting['edge_style'], - marker='o', markersize=G.plotting['vertex_size'], + marker='o', markersize=vertex_size, markerfacecolor=G.plotting['vertex_color']) if G.coords.shape[1] == 3: @@ -215,19 +246,19 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name y3 = y2[i:i + 2] z3 = z2[i:i + 2] ax.plot(x3, y3, z3, linewidth=G.plotting['edge_width'], - color=G.plotting['edge_color'], + color=edge_color, linestyle=G.plotting['edge_style'], - marker='o', markersize=G.plotting['vertex_size'], + marker='o', markersize=vertex_size, markerfacecolor=G.plotting['vertex_color']) else: # TODO: is ax.plot(G.coords[:, 0], G.coords[:, 1], 'bo') faster? if G.coords.shape[1] == 2: ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', - s=G.plotting['vertex_size'], + s=vertex_size, c=G.plotting['vertex_color']) if G.coords.shape[1] == 3: ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], - marker='o', s=G.plotting['vertex_size'], + marker='o', s=vertex_size, c=G.plotting['vertex_color']) # Save plot as PNG or show it in a window @@ -240,12 +271,9 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() -def _pg_plot_graph(G, show_edges=None, plot_name=''): +def _qtg_plot_graph(G, show_edges=None, plot_name=''): # TODO handling when G is a list of graphs - global window_list - if 'window_list' not in globals(): - window_list = {} if show_edges is None: show_edges = G.Ne < 10000 @@ -258,42 +286,43 @@ def _pg_plot_graph(G, show_edges=None, plot_name=''): adj = np.concatenate((np.expand_dims(ki, axis=1), np.expand_dims(kj, axis=1)), axis=1) - w = pg.GraphicsWindow() - w.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) - v = w.addViewBox() - v.setAspectLocked() + window = qtg.GraphicsWindow() + window.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) + view = window.addViewBox() + view.setAspectLocked() extra_args = {} if isinstance(G.plotting['vertex_color'], list): - extra_args['symbolPen'] = [pg.mkPen(v_col) for v_col in G.plotting['vertex_color']] - extra_args['brush'] = [pg.mkBrush(v_col) for v_col in G.plotting['vertex_color']] + extra_args['symbolPen'] = [qtg.mkPen(v_col) for v_col in G.plotting['vertex_color']] + extra_args['brush'] = [qtg.mkBrush(v_col) for v_col in G.plotting['vertex_color']] elif isinstance(G.plotting['vertex_color'], int): extra_args['symbolPen'] = G.plotting['vertex_color'] extra_args['brush'] = G.plotting['vertex_color'] # Define syntaxic sugar mapping keywords for the display options - for plot_args, pg_args in [('vertex_size', 'size'), ('vertex_mask', 'mask'), ('edge_color', 'pen')]: + for plot_args, qtg_args in [('vertex_size', 'size'), ('vertex_mask', 'mask'), ('edge_color', 'pen')]: if plot_args in G.plotting: - G.plotting[pg_args] = G.plotting.pop(plot_args) + G.plotting[qtg_args] = G.plotting.pop(plot_args) - for pg_args in ['size', 'mask', 'pen', 'symbolPen']: - if pg_args in G.plotting: - extra_args[pg_args] = G.plotting[pg_args] + for qtg_args in ['size', 'mask', 'pen', 'symbolPen']: + if qtg_args in G.plotting: + extra_args[qtg_args] = G.plotting[qtg_args] if not show_edges: extra_args['pen'] = None - g = pg.GraphItem(pos=G.coords, adj=adj, **extra_args) - v.addItem(g) + g = qtg.GraphItem(pos=G.coords, adj=adj, **extra_args) + view.addItem(g) - window_list[str(uuid.uuid4())] = w + global _qtg_windows + _qtg_windows.append(window) elif G.coords.shape[1] == 3: - app = QtGui.QApplication([]) - w = gl.GLViewWidget() - w.opts['distance'] = 10 - w.show() - w.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) + application = QtGui.QApplication([]) + widget = gl.GLViewWidget() + widget.opts['distance'] = 10 + widget.show() + widget.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) # Very dirty way to display a 3d graph x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), @@ -320,29 +349,34 @@ def _pg_plot_graph(G, show_edges=None, plot_name=''): extra_args = {'color': (0, 0, 1, 1)} if 'vertex_color' in G.plotting: if isinstance(G.plotting['vertex_color'], list): - extra_args['color'] = np.array([pg.glColor(pg.mkPen(v_col).color()) for v_col in G.plotting['vertex_color']]) + extra_args['color'] = np.array([qtg.glColor(qtg.mkPen(v_col).color()) for v_col in G.plotting['vertex_color']]) elif isinstance(G.plotting['vertex_color'], int): - extra_args['color'] = pg.glColor(pg.mkPen(G.plotting['vertex_color']).color()) + extra_args['color'] = qtg.glColor(qtg.mkPen(G.plotting['vertex_color']).color()) else: extra_args['color'] = G.plotting['vertex_color'] # Define syntaxic sugar mapping keywords for the display options - for plot_args, pg_args in [('vertex_size', 'size')]: + for plot_args, qtg_args in [('vertex_size', 'size')]: if plot_args in G.plotting: - G.plotting[pg_args] = G.plotting.pop(plot_args) + G.plotting[qtg_args] = G.plotting.pop(plot_args) - for pg_args in ['size']: - if pg_args in G.plotting: - extra_args[pg_args] = G.plotting[pg_args] + for qtg_args in ['size']: + if qtg_args in G.plotting: + extra_args[qtg_args] = G.plotting[qtg_args] if show_edges: - g = gl.GLLinePlotItem(pos=pts, mode='lines', color=G.plotting['edge_color']) - w.addItem(g) + try: + edge_color = G.plotting['edge_color'] + except KeyError: + edge_color = np.array([255, 88, 41]) / 255. + g = gl.GLLinePlotItem(pos=pts, mode='lines', color=edge_color) + widget.addItem(g) gp = gl.GLScatterPlotItem(pos=G.coords, **extra_args) - w.addItem(gp) + widget.addItem(gp) - window_list[str(uuid.uuid4())] = app + global _qtg_applications + _qtg_applications.append(application) def plot_filter(filters, npoints=1000, line_width=4, x_width=3, @@ -402,9 +436,10 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, fd = filters.evaluate(lambdas) # Plot the filter + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) size = len(fd) - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 ax = fig.add_subplot(111) if len(filters.g) == 1: ax.plot(lambdas, fd, linewidth=line_width) @@ -484,7 +519,7 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): 'Please run G.set_coords() first.') if qtg_import and (default_qtg or not plt_import): - _pg_plot_signal(G, signal, **kwargs) + _qtg_plot_signal(G, signal, **kwargs) elif plt_import and not (default_qtg and qtg_import): _plt_plot_signal(G, signal, **kwargs) else: @@ -497,8 +532,9 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], colorbar=True, bar=False, bar_width=1, savefig=False, show_plot=False, plot_name=None): - fig = plt.figure(plid.plot_id) - plid.plot_id += 1 + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") @@ -519,7 +555,6 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], elif G.coords.shape[1] == 3: ax = fig.add_subplot(111, projection='3d') - # Plot edges if show_edges: ki, kj = np.nonzero(G.A) @@ -575,7 +610,7 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], plt.show(False) # non blocking show -def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], +def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], vertex_size=None, vertex_highlight=False, climits=None, colorbar=True, bar=False, bar_width=1, plot_name=None): @@ -591,20 +626,15 @@ def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], cmax = 1.01 * np.max(signal) climits = [cmin, cmax] - # pygtgraph window initialization in 2D and 3D - global window_list - if 'window_list' not in globals(): - window_list = {} - if G.coords.shape[1] == 2: - w = pg.GraphicsWindow(plot_name or G.gtype) - v = w.addViewBox() + window = qtg.GraphicsWindow(plot_name or G.gtype) + view = window.addViewBox() elif G.coords.shape[1] == 3: - app = QtGui.QApplication([]) - w = gl.GLViewWidget() - w.opts['distance'] = 10 - w.show() - w.setWindowTitle(plot_name or G.gtype) + application = QtGui.QApplication([]) + widget = gl.GLViewWidget() + widget.opts['distance'] = 10 + widget.show() + widget.setWindowTitle(plot_name or G.gtype) # Plot edges if show_edges: @@ -616,9 +646,9 @@ def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], adj = np.concatenate((np.expand_dims(ki, axis=1), np.expand_dims(kj, axis=1)), axis=1) - g = pg.GraphItem(pos=G.coords, adj=adj, symbolBrush=None, + g = qtg.GraphItem(pos=G.coords, adj=adj, symbolBrush=None, symbolPen=None) - v.addItem(g) + view.addItem(g) if G.coords.shape[1] == 3: # Very dirty way to display a 3d graph @@ -647,14 +677,14 @@ def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], gp = gl.GLScatterPlotItem(pos=G.coords, color=(1., 0., 0., 1)) - w.addItem(g) - w.addItem(gp) + widget.addItem(g) + widget.addItem(gp) # Plot signal on top pos = [1, 8, 24, 40, 56, 64] color = np.array([[0, 0, 143, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 0, 255], [255, 0, 0, 255], [128, 0, 0, 255]]) - cmap = pg.ColorMap(pos, color) + cmap = qtg.ColorMap(pos, color) mininum = min(signal) maximum = max(signal) @@ -662,22 +692,22 @@ def _pg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], normalized_signal = [1 + 63 *(float(x) - mininum) / (maximum - mininum) for x in signal] if G.coords.shape[1] == 2: - gp = pg.ScatterPlotItem(G.coords[:, 0], + gp = qtg.ScatterPlotItem(G.coords[:, 0], G.coords[:, 1], size=vertex_size, brush=cmap.map(normalized_signal, 'qcolor')) - v.addItem(gp) + view.addItem(gp) if G.coords.shape[1] == 3: - gp = gl.GLScatterPlotItem(G.coords[:, 0], G.coords[:, 1], - G.coords[:, 2], size=vertex_size, c=signal) - w.addItem(gp) - + gp = gl.GLScatterPlotItem(pos=G.coords, size=vertex_size, color=signal) + widget.addItem(gp) # Multiple windows handling if G.coords.shape[1] == 2: - window_list[str(uuid.uuid4())] = w + global _qtg_windows + _qtg_windows.append(window) elif G.coords.shape[1] == 3: - window_list[str(uuid.uuid4())] = app + global _qtg_applications + _qtg_applications.append(application) def plot_spectrogram(G, node_idx=None): @@ -699,10 +729,7 @@ def plot_spectrogram(G, node_idx=None): >>> plotting.plot_spectrogram(G) """ - global window_list from pygsp.features import compute_spectrogram - if 'window_list' not in globals(): - window_list = {} if not qtg_import: raise NotImplementedError("You need pyqtgraph to plot the spectrogram at the moment. Please install dependency and retry.") @@ -717,16 +744,17 @@ def plot_spectrogram(G, node_idx=None): pos = np.array([0., 0.25, 0.5, 0.75, 1.]) color = np.array([[20, 133, 212, 255], [53, 42, 135, 255], [48, 174, 170, 255], [210, 184, 87, 255], [249, 251, 14, 255]], dtype=np.ubyte) - cmap = pg.ColorMap(pos, color) + cmap = qtg.ColorMap(pos, color) - w = pg.GraphicsWindow() + w = qtg.GraphicsWindow() w.setWindowTitle("Spectrogramm of {}".format(G.gtype)) v = w.addPlot(labels={'bottom': 'nodes', 'left': 'frequencies {}:{:.2f}:{:.2f}'.format(0, G.lmax/M, G.lmax)}) v.setAspectLocked() - spi = pg.ScatterPlotItem(np.repeat(np.arange(G.N), M), np.ravel(np.tile(np.arange(M), (1, G.N))), pxMode=False, symbol='s', + spi = qtg.ScatterPlotItem(np.repeat(np.arange(G.N), M), np.ravel(np.tile(np.arange(M), (1, G.N))), pxMode=False, symbol='s', size=1, brush=cmap.map((spectr.astype(float) - min_spec)/(max_spec - min_spec), 'qcolor')) v.addItem(spi) - window_list[str(uuid.uuid4())] = w + global _qtg_windows + _qtg_windows.append(w) From de0ad2859c1e833f84f2b8659b652e16b21a530d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 20:25:13 +0200 Subject: [PATCH 128/392] test: extensive tests of graph plots --- pygsp/tests/test_plotting.py | 40 ++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 0485ad6a..86c8d1c6 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -11,7 +11,7 @@ import numpy as np from skimage import data, img_as_float -from pygsp import graphs +from pygsp import graphs, plotting class FunctionsTestCase(unittest.TestCase): @@ -22,11 +22,16 @@ def setUp(self): def tearDown(self): pass - def test_is_plottable(self): + def test_plot_graphs(self): + r""" + Plot all graphs which have coordinates. + With and without signal. + With both backends. + """ classnames = graphs.__all__ - # Graphs who are not embedded. + # Graphs who are not embedded, i.e. have no coordinates. classnames.remove('Graph') classnames.remove('BarabasiAlbert') classnames.remove('ErdosRenyi') @@ -36,6 +41,18 @@ def test_is_plottable(self): classnames.remove('Ring') # TODO: should have! classnames.remove('StochasticBlockModel') + # Coordinates are not in 2D or 3D. + classnames.remove('ImgPatches') + + # TODO: 3D graphics don't work with xvfb-run. + # Uncomment and launch tests with python setup.py test. + classnames.remove('SwissRoll') + classnames.remove('Torus') + classnames.remove('NNGraph') + classnames.remove('Bunny') + classnames.remove('Cube') + classnames.remove('Sphere') + Gs = [] for classname in classnames: Graph = getattr(graphs, classname) @@ -62,10 +79,25 @@ def test_is_plottable(self): Gs.append(Graph(N=128)) for G in Gs: - # Check attributes. 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 + + if G.is_directed(): + self.assertRaises(NotImplementedError, + G.plot, default_qtg=True) + self.assertRaises(NotImplementedError, + G.plot, default_qtg=False) + else: + # Backend: pyqtgraph. + G.plot(default_qtg=True) + G.plot_signal(signal, default_qtg=True) + # Backend: matplotlib. + G.plot(default_qtg=False) + G.plot_signal(signal, default_qtg=False) + plotting.close_all() + suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) From bc71ca55093a3c2cb965e54e93ab9542352d363f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 17 Aug 2017 21:08:57 +0200 Subject: [PATCH 129/392] tests: run 3D OpenGL stuff on virtual framebuffer --- Makefile | 5 ++++- pygsp/plotting.py | 1 + pygsp/tests/test_all.py | 4 ++-- pygsp/tests/test_plotting.py | 9 --------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index e1ef1958..4e695aaa 100644 --- a/Makefile +++ b/Makefile @@ -25,9 +25,12 @@ lint: flake8 --doctests test: - xvfb-run coverage run --branch --source pygsp setup.py test + Xvfb :5 -screen 0 800x600x24 & + export DISPLAY=:5 + coverage run --branch --source pygsp setup.py test coverage report coverage html + killall Xvfb doc: sphinx-build -b html -d doc/_build/doctrees doc doc/_build/html diff --git a/pygsp/plotting.py b/pygsp/plotting.py index d24968fa..436f72de 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -53,6 +53,7 @@ def close_all(): _qtg_windows = [] # Segmentation faults when executing test_plotting. + # TODO: find how to quit and close properly. # global _qtg_applications # for application in _qtg_applications: # application.quit() diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index d13dae40..a626fd8e 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -27,11 +27,11 @@ def test_docstrings(root, ext): suites = [] suites.append(test_graphs.suite) -suites.append(test_utils.suite) suites.append(test_filters.suite) -suites.append(test_plotting.suite) +suites.append(test_utils.suite) suites.append(test_docstrings('pygsp', '.py')) suites.append(test_docstrings('.', '.rst')) +suites.append(test_plotting.suite) # TODO: can SIGSEGV if not at the end suite = unittest.TestSuite(suites) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 86c8d1c6..1dd233c5 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -44,15 +44,6 @@ def test_plot_graphs(self): # Coordinates are not in 2D or 3D. classnames.remove('ImgPatches') - # TODO: 3D graphics don't work with xvfb-run. - # Uncomment and launch tests with python setup.py test. - classnames.remove('SwissRoll') - classnames.remove('Torus') - classnames.remove('NNGraph') - classnames.remove('Bunny') - classnames.remove('Cube') - classnames.remove('Sphere') - Gs = [] for classname in classnames: Graph = getattr(graphs, classname) From 47a2791002e167500a867167a1dde21cb02cdce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 08:55:44 +0200 Subject: [PATCH 130/392] travis: start xvfb as advised by documentation --- .travis.yml | 15 ++++++++++----- Makefile | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index b93368e9..8bb5bfbf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,10 +4,10 @@ sudo: false language: python cache: pip # cache wheels for faster tests (PySide build can timeout) python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" + - 2.7 + - 3.4 + - 3.5 + - 3.6 addons: apt: @@ -19,9 +19,14 @@ install: - pip install -U -r requirements.txt - pip install . +before_script: + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + - sleep 3 + script: # - make lint - - make test + - coverage run --branch --source pygsp setup.py test - make doc after_success: diff --git a/Makefile b/Makefile index 4e695aaa..ce5c0d49 100644 --- a/Makefile +++ b/Makefile @@ -25,8 +25,8 @@ lint: flake8 --doctests test: - Xvfb :5 -screen 0 800x600x24 & - export DISPLAY=:5 + Xvfb :99 -screen 0 800x600x24 & + export DISPLAY=:99 coverage run --branch --source pygsp setup.py test coverage report coverage html From 1d34c3f5573b5d4b38134171fd48cf89decec718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 09:01:08 +0200 Subject: [PATCH 131/392] test_plotting: use sets --- pygsp/tests/test_plotting.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 1dd233c5..dde2424b 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -29,23 +29,23 @@ def test_plot_graphs(self): With both backends. """ - classnames = graphs.__all__ - # Graphs who are not embedded, i.e. have no coordinates. - classnames.remove('Graph') - classnames.remove('BarabasiAlbert') - classnames.remove('ErdosRenyi') - classnames.remove('FullConnected') - classnames.remove('RandomRegular') - classnames.remove('RandomRing') - classnames.remove('Ring') # TODO: should have! - classnames.remove('StochasticBlockModel') + COORDS_NO = { + 'Graph', + 'BarabasiAlbert', + 'ErdosRenyi', + 'FullConnected', + 'RandomRegular', + 'RandomRing', + 'Ring', # TODO: should have! + 'StochasticBlockModel', + } # Coordinates are not in 2D or 3D. - classnames.remove('ImgPatches') + COORDS_WRONG_DIM = {'ImgPatches'} Gs = [] - for classname in classnames: + for classname in set(graphs.__all__) - COORDS_NO - COORDS_WRONG_DIM: Graph = getattr(graphs, classname) # Classes who require parameters. From ff911df39dffd50d981070c9124dacee3a40cabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 09:37:55 +0200 Subject: [PATCH 132/392] makefile: DISPLAY was not exported... --- .travis.yml | 7 +------ Makefile | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8bb5bfbf..c3f19abb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,14 +19,9 @@ install: - pip install -U -r requirements.txt - pip install . -before_script: - - export DISPLAY=:99.0 - - sh -e /etc/init.d/xvfb start - - sleep 3 - script: # - make lint - - coverage run --branch --source pygsp setup.py test + - make test - make doc after_success: diff --git a/Makefile b/Makefile index ce5c0d49..8504d1dd 100644 --- a/Makefile +++ b/Makefile @@ -24,9 +24,9 @@ clean: lint: flake8 --doctests +export DISPLAY = :99 test: - Xvfb :99 -screen 0 800x600x24 & - export DISPLAY=:99 + Xvfb $$DISPLAY -screen 0 800x600x24 & coverage run --branch --source pygsp setup.py test coverage report coverage html From 0690fb817735e4b471da1d097019efbf96442adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 10:12:33 +0200 Subject: [PATCH 133/392] plotting: close OpenGL widgets and keep only one Qt application --- pygsp/plotting.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 436f72de..7a674407 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -25,8 +25,9 @@ try: import pyqtgraph as qtg - from pyqtgraph.Qt import QtGui import pyqtgraph.opengl as gl + from pyqtgraph.Qt import QtGui + _qtg_application = QtGui.QApplication([]) qtg_import = True except Exception as e: print('ERROR : Could not import packages for pyqtgraph.') @@ -35,7 +36,7 @@ _qtg_windows = [] -_qtg_applications = [] +_qtg_widgets = [] _plt_figures = [] @@ -46,18 +47,16 @@ def close_all(): """ # Windows can be closed by releasing all references to them so they can be - # garbage collected. Not necessary to call close(). + # garbage collected. May not be necessary to call close(). global _qtg_windows for window in _qtg_windows: window.close() _qtg_windows = [] - # Segmentation faults when executing test_plotting. - # TODO: find how to quit and close properly. - # global _qtg_applications - # for application in _qtg_applications: - # application.quit() - # _qtg_applications = [] + global _qtg_widgets + for widget in _qtg_widgets: + widget.close() + _qtg_widgets = [] global _plt_figures for fig in _plt_figures: @@ -319,7 +318,6 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): _qtg_windows.append(window) elif G.coords.shape[1] == 3: - application = QtGui.QApplication([]) widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() @@ -376,8 +374,8 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): gp = gl.GLScatterPlotItem(pos=G.coords, **extra_args) widget.addItem(gp) - global _qtg_applications - _qtg_applications.append(application) + global _qtg_widgets + _qtg_widgets.append(widget) def plot_filter(filters, npoints=1000, line_width=4, x_width=3, @@ -631,7 +629,6 @@ def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], window = qtg.GraphicsWindow(plot_name or G.gtype) view = window.addViewBox() elif G.coords.shape[1] == 3: - application = QtGui.QApplication([]) widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() @@ -707,8 +704,8 @@ def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], global _qtg_windows _qtg_windows.append(window) elif G.coords.shape[1] == 3: - global _qtg_applications - _qtg_applications.append(application) + global _qtg_widgets + _qtg_widgets.append(widget) def plot_spectrogram(G, node_idx=None): From 96e50e5874a62768794b4258310febb88540f55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 10:57:45 +0200 Subject: [PATCH 134/392] makefile: only export DISPLAY when running tests --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8504d1dd..9229dc8f 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ clean: lint: flake8 --doctests -export DISPLAY = :99 +test: export DISPLAY = :99 test: Xvfb $$DISPLAY -screen 0 800x600x24 & coverage run --branch --source pygsp setup.py test From bc53536d20f2c897607b197e1606eb6ade4a03bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 11:40:10 +0200 Subject: [PATCH 135/392] plotting: only create Qt application if needed as it requires X server --- pygsp/plotting.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 7a674407..592d508d 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -27,7 +27,6 @@ import pyqtgraph as qtg import pyqtgraph.opengl as gl from pyqtgraph.Qt import QtGui - _qtg_application = QtGui.QApplication([]) qtg_import = True except Exception as e: print('ERROR : Could not import packages for pyqtgraph.') @@ -318,6 +317,9 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): _qtg_windows.append(window) elif G.coords.shape[1] == 3: + if not QtGui.QApplication.instance(): + # We want only one application. + QtGui.QApplication([]) widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() @@ -629,6 +631,9 @@ def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], window = qtg.GraphicsWindow(plot_name or G.gtype) view = window.addViewBox() elif G.coords.shape[1] == 3: + if not QtGui.QApplication.instance(): + # We want only one application. + QtGui.QApplication([]) widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() From b3b6186cc58016a1b3f91e3363f16fafb65bbd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 12:38:08 +0200 Subject: [PATCH 136/392] tests: setup and teardown only once per class --- pygsp/tests/test_filters.py | 9 +++++---- pygsp/tests/test_graphs.py | 8 +++++--- pygsp/tests/test_plotting.py | 8 +++++--- pygsp/tests/test_utils.py | 6 ++++-- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index dfb30942..c36f354e 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -15,11 +15,12 @@ class FunctionsTestCase(unittest.TestCase): - def setUp(self): - self._G = graphs.Logo() - self._G.estimate_lmax() + @classmethod + def setUpClass(cls): + cls._G = graphs.Logo() - def tearDown(self): + @classmethod + def tearDownClass(cls): pass def _fu(x): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 88f1afec..666e2b6b 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -16,10 +16,12 @@ class FunctionsTestCase(unittest.TestCase): - def setUp(self): - self._img = img_as_float(data.camera()[::16, ::16]) + @classmethod + def setUpClass(cls): + cls._img = img_as_float(data.camera()[::16, ::16]) - def tearDown(self): + @classmethod + def tearDownClass(cls): pass def test_default_graph(self): diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index dde2424b..e7dfb196 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -16,10 +16,12 @@ class FunctionsTestCase(unittest.TestCase): - def setUp(self): - self._img = img_as_float(data.camera()[::16, ::16]) + @classmethod + def setUpClass(cls): + cls._img = img_as_float(data.camera()[::16, ::16]) - def tearDown(self): + @classmethod + def tearDownClass(cls): pass def test_plot_graphs(self): diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 0a5c1765..7b8adc83 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -16,10 +16,12 @@ class FunctionsTestCase(unittest.TestCase): - def setUp(self): + @classmethod + def setUpClass(cls): pass - def tearDown(self): + @classmethod + def tearDownClass(cls): pass def test_utils(self): From 206aa952ea09f2795ec503ff5b3e62ea555f832e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 12:43:32 +0200 Subject: [PATCH 137/392] filters: doctest example for synthesis --- pygsp/filters/filter.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 17434737..66d81560 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -185,13 +185,25 @@ def synthesis(self, c, order=30, method=None, **kwargs): ------- signal : synthesis signal - Examples - -------- - References ---------- See :cite:`hammond2011wavelets` for more details. + Examples + -------- + >>> from pygsp import graphs, filters + >>> G = graphs.Logo() + >>> Nf = 6 + >>> + >>> vertex_delta = 83 + >>> S = np.zeros((G.N * Nf, Nf)) + >>> S[vertex_delta] = 1 + >>> for i in range(Nf): + ... S[vertex_delta + i * G.N, i] = 1 + >>> + >>> Wk = filters.MexicanHat(G, Nf) + >>> Sf = Wk.synthesis(S) + """ Nf = len(self.g) N = self.G.N From 3016b351393afa9be90cb2419c95b6678b92624a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 12:44:33 +0200 Subject: [PATCH 138/392] filters: correct signature for approx and tighten --- pygsp/filters/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 66d81560..674be1a9 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -264,13 +264,13 @@ def synthesis(self, c, order=30, method=None, **kwargs): return s - def approx(m, N, **kwargs): + def approx(self, m, N, **kwargs): r""" Not implemented yet. """ raise NotImplementedError - def tighten(): + def tighten(self): r""" Not implemented yet. """ From 142e1e405cadcbe40e8b39c0921001ffa4cd252f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 12:47:16 +0200 Subject: [PATCH 139/392] filters: warn correctly --- pygsp/filters/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 674be1a9..2f67140a 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -235,7 +235,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): s += igft(np.conjugate(self.G.U), fc) elif method == 'cheby': - if hasattr(self.G, 'lmax'): + if not hasattr(self.G, 'lmax'): self.logger.info('The variable lmax is not available. ' 'The function will compute it for you.') self.G.estimate_lmax() From e3bfa51ebaca4724863988d3f4ee2c59c5dce4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 12:48:22 +0200 Subject: [PATCH 140/392] filters: edit log messages --- pygsp/filters/filter.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 2f67140a..da7d2976 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -75,7 +75,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, if method == 'cheby': # Chebyshev approx if not hasattr(self.G, 'lmax'): - self.logger.info('FILTER_ANALYSIS: computing lmax.') + self.logger.info('Computing lmax.') self.G.estimate_lmax() cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) @@ -87,8 +87,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, elif method == 'exact': # Exact computation if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self.logger.info('The Fourier matrix is not available. ' - 'The function will compute it for you.') + self.logger.info('Computing the Fourier matrix.') self.G.compute_fourier_basis() Nf = len(self.g) # nb of filters @@ -120,8 +119,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, c[tmpN + N * i] = igft(self.G, fs) else: - raise ValueError('Unknown method: please select exact, ' - 'cheby or lanczos') + raise ValueError('Unknown method: {}'.format(method)) return c @@ -216,8 +214,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): if method == 'exact': if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self.logger.info("The Fourier matrix is not available. " - "The function will compute it for you.") + self.logger.info('Computing the Fourier matrix.') self.G.compute_fourier_basis() fie = self.evaluate(self.G.e) @@ -236,8 +233,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): elif method == 'cheby': if not hasattr(self.G, 'lmax'): - self.logger.info('The variable lmax is not available. ' - 'The function will compute it for you.') + self.logger.info('Computing lmax.') self.G.estimate_lmax() cheb_coeffs = approximations.compute_cheby_coeff( @@ -259,8 +255,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): order=order) else: - raise ValueError('Unknown method: please select exact,' - ' cheby or lanczos') + raise ValueError('Unknown method: {}'.format(method)) return s From 48e3c3eb994be4234ce40c25c513ff27c0363490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 13:11:05 +0200 Subject: [PATCH 141/392] extensive tests for filters --- pygsp/tests/test_filters.py | 142 +++++++++++++++++++++++++----------- 1 file changed, 99 insertions(+), 43 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index c36f354e..c7c1bee3 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -18,67 +18,126 @@ class FunctionsTestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls._G = graphs.Logo() + # Signal is a Kronecker delta at node 83. + cls._signal = np.zeros(cls._G.N) + cls._signal[83] = 1 @classmethod def tearDownClass(cls): pass - def _fu(x): + def _filter(self, x): return x / (1. + x) - def test_default_filters(self): - filters.Filter(self._G) - filters.Filter(self._G, filters=self._fu) + def _test_synthesis(self, f): + Nf = len(f.g) + if 1 < Nf < 10: - def test_abspline(self): - filters.Abspline(self._G, Nf=4) + vertex_delta = 83 + S = np.zeros((self._G.N * Nf, Nf)) + S[vertex_delta] = 1 + for i in range(Nf): + S[vertex_delta + i * self._G.N, i] = 1 - def test_expwin(self): - filters.Expwin(self._G) + f.synthesis(S, method='cheby') + f.synthesis(S, method='exact') - def test_gabor(self): - filters.Gabor(self._G, self._fu) + def _test_methods(self, f): + self.assertIs(f.G, self._G) - def test_halfcosine(self): - filters.HalfCosine(self._G, Nf=4) + f.analysis(self._signal, method='exact') + f.analysis(self._signal, method='cheby') - def test_heat(self): - filters.Heat(self._G) + self._test_synthesis(f) + f.evaluate(np.ones(10)) - def test_held(self): - filters.Held(self._G) - filters.Held(self._G, a=0.25) + f.filterbank_bounds() + # f.filterbank_matrix() TODO: too much memory + + f.wlog_scales(1, 10, 10) + + self.assertRaises(NotImplementedError, f.approx, 0, 0) + self.assertRaises(NotImplementedError, f.inverse, 0) + self.assertRaises(NotImplementedError, f.tighten) + + def test_custom_filter(self): + f = filters.Filter(self._G, filters=self._filter) + self._test_methods(f) + + def test_abspline(self): + f = filters.Abspline(self._G, Nf=4) + self._test_methods(f) + + def test_gabor(self): + f = filters.Gabor(self._G, self._filter) + self._test_methods(f) + + def test_halfcosine(self): + f = filters.HalfCosine(self._G, Nf=4) + self._test_methods(f) def test_itersine(self): - filters.Itersine(self._G, Nf=4) + f = filters.Itersine(self._G, Nf=4) + self._test_methods(f) def test_mexicanhat(self): - filters.MexicanHat(self._G, Nf=5) - filters.MexicanHat(self._G, Nf=4) + f = filters.MexicanHat(self._G, Nf=5) + self._test_methods(f) + f = filters.MexicanHat(self._G, Nf=4) + self._test_methods(f) def test_meyer(self): - filters.Meyer(self._G, Nf=4) + f = filters.Meyer(self._G, Nf=4) + self._test_methods(f) - def test_papadakis(self): - filters.Papadakis(self._G) - filters.Papadakis(self._G, a=0.25) + def test_simpletf(self): + f = filters.SimpleTf(self._G, Nf=4) + self._test_methods(f) + + def test_warpedtranslates(self): + self.assertRaises(NotImplementedError, + filters.WarpedTranslates, self._G) + pass def test_regular(self): - filters.Regular(self._G) - filters.Regular(self._G, d=5) - filters.Regular(self._G, d=0) + f = filters.Regular(self._G) + self._test_methods(f) + f = filters.Regular(self._G, d=5) + self._test_methods(f) + f = filters.Regular(self._G, d=0) + self._test_methods(f) + + def test_held(self): + f = filters.Held(self._G) + self._test_methods(f) + f = filters.Held(self._G, a=0.25) + self._test_methods(f) def test_simoncelli(self): - filters.Simoncelli(self._G) - filters.Simoncelli(self._G, a=0.25) + f = filters.Simoncelli(self._G) + self._test_methods(f) + f = filters.Simoncelli(self._G, a=0.25) + self._test_methods(f) - def test_simpletf(self): - filters.SimpleTf(self._G, Nf=4) + def test_papadakis(self): + f = filters.Papadakis(self._G) + self._test_methods(f) + f = filters.Papadakis(self._G, a=0.25) + self._test_methods(f) - # Warped translates are not implemented yet - def test_warpedtranslates(self): - pass - # gw = filters.warpedtranslates(G, g)) + def test_heat(self): + f = filters.Heat(self._G, normalize=False, tau=10) + self._test_methods(f) + f = filters.Heat(self._G, normalize=False, tau=[5, 10]) + self._test_methods(f) + f = filters.Heat(self._G, normalize=True, tau=10) + self._test_methods(f) + f = filters.Heat(self._G, normalize=True, tau=[5, 10]) + self._test_methods(f) + + def test_expwin(self): + f = filters.Expwin(self._G) + self._test_methods(f) def test_approximations(self): r""" @@ -86,16 +145,13 @@ def test_approximations(self): 'cheby', and 'lanczos', produce the same output. """ - # Signal is a Kronecker delta at node 83. - s = np.zeros(self._G.N) - s[83] = 1 - - g = filters.Heat(self._G) - c_exact = g.analysis(s, method='exact') - c_cheby = g.analysis(s, method='cheby') + f = filters.Heat(self._G) + c_exact = f.analysis(self._signal, method='exact') + c_cheby = f.analysis(self._signal, method='cheby') assert np.allclose(c_exact, c_cheby) - self.assertRaises(NotImplementedError, g.analysis, s, method='lanczos') + self.assertRaises(NotImplementedError, f.analysis, + self._signal, method='lanczos') suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) From c8fb4e913e5a06fa214a885b6a8637c15df70493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 14:08:23 +0200 Subject: [PATCH 142/392] filters: float division for python 2 --- pygsp/filters/filter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index da7d2976..22595ee9 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + from math import log from copy import deepcopy From 4f25bd7d4d1ccac88833bbe35bbd05e5ab4fb8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 13:14:42 +0200 Subject: [PATCH 143/392] docstring for filter base class --- pygsp/filters/filter.py | 43 +++++++++++++++++++++++++++++++++++++---- pygsp/graphs/graph.py | 11 +++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 22595ee9..8e97a509 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -14,8 +14,43 @@ class Filter(object): r""" - Base class for all filters or filterbanks. - Define the interface and implement shared methods. + The base Filter class. + + * Provide a common interface (and implementation) to filter objects. + * Can be instantiated to construct custom filters from functions. + * Initialize attributes for derived classes. + + Parameters + ---------- + G : graph + The graph to which the filterbank is tailored. + filters : function or list of functions + A (list of) function defining the filterbank. One function per filter. + + Attributes + ---------- + G : Graph + The graph to which the filterbank was tailored. It is a reference to + the graph passed when instantiating the class. + g : function or list of functions + A (list of) function defining the filterbank. One function per filter. + Either passed by the user when instantiating the base class, either + constructed by the derived classes. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, filters + >>> + >>> G = graphs.Logo() + >>> my_filter = filters.Filter(G, lambda x: x / (1. + x)) + >>> + >>> # Signal: Kronecker delta. + >>> signal = np.zeros(G.N) + >>> signal[42] = 1 + >>> + >>> filtered_signal = my_filter.analysis(signal) + """ def __init__(self, G, filters=None, **kwargs): @@ -244,8 +279,8 @@ def synthesis(self, c, order=30, method=None, **kwargs): tmpN = np.arange(N, dtype=int) for i in range(Nf): - s = s + approximations.cheby_op(self.G, - cheb_coeffs[i], c[i * N + tmpN]) + s += approximations.cheby_op(self.G, + cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': s = np.zeros((N, np.shape(c)[1])) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5da28304..d027a5e1 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -13,7 +13,7 @@ class Graph(object): r""" The base graph class. - * Provide a common interface to graph objects. + * Provide a common interface (and implementation) to graph objects. * Can be instantiated to construct custom graphs from a weight matrix. * Initialize attributes for derived classes. @@ -34,7 +34,6 @@ class Graph(object): Attributes ---------- - N : int the number of nodes / vertices in the graph. Ne : int @@ -586,6 +585,10 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, order that the eigenvalues. Finally, the coherence of the Fourier basis is in *G.mu*. + References + ---------- + See :cite:`chung1997spectral` + Examples -------- >>> from pygsp import graphs @@ -593,10 +596,6 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, >>> G = graphs.Sensor(N) >>> G.compute_fourier_basis() - References - ---------- - See :cite:`chung1997spectral` - """ if hasattr(self, 'e') and hasattr(self, 'U') and not recompute: return From 2756c4872a42ef09fcc363b228260567f74dba97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 13:52:31 +0200 Subject: [PATCH 144/392] filters: base class initialize attributes --- doc/tutorials/intro.rst | 18 ++++++----------- pygsp/filters/abspline.py | 16 +++++++++++---- pygsp/filters/expwin.py | 4 ++-- pygsp/filters/filter.py | 33 +++++++++++-------------------- pygsp/filters/gabor.py | 7 ++++--- pygsp/filters/halfcosine.py | 3 +-- pygsp/filters/heat.py | 3 +-- pygsp/filters/held.py | 5 ++--- pygsp/filters/itersine.py | 3 +-- pygsp/filters/mexicanhat.py | 3 +-- pygsp/filters/meyer.py | 12 ++++++----- pygsp/filters/papadakis.py | 5 ++--- pygsp/filters/regular.py | 5 ++--- pygsp/filters/simoncelli.py | 5 ++--- pygsp/filters/simpletf.py | 3 +-- pygsp/filters/warpedtranslates.py | 1 - 16 files changed, 56 insertions(+), 70 deletions(-) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 574508b7..976bc63b 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -55,25 +55,19 @@ Let's plot the second and third eigenvectors, as the first is constant. Let's discover basic filters operations, filters are usually defined in the spectral domain. -First let's define a filter object: +Given the transfer function -.. plot:: - :context: close-figs - - >>> F = filters.Filter(G) - -And we can assign this function - -.. math:: \begin{equation*} g(x) =\frac{1}{1+\tau x} \end{equation*} +.. math:: \begin{equation*} g(x) =\frac{1}{1+\tau x} \end{equation*}, -to it: +let's define a filter object: .. plot:: :context: close-figs >>> tau = 1 - >>> g = lambda x: 1./(1. + tau * x) - >>> F.g = [g] + >>> def g(x): + ... return 1. / (1. + tau * x) + >>> F = filters.Filter(G, g) You can also put multiple functions in a list to define a filterbank! diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 9bc9afff..067d44e3 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -4,6 +4,7 @@ from scipy import optimize from . import Filter +from pygsp import utils class Abspline(Filter): @@ -32,7 +33,8 @@ class Abspline(Filter): """ def __init__(self, G, Nf=6, lpfactor=20, t=None, **kwargs): - super(Abspline, self).__init__(G, **kwargs) + + self._logger = utils.build_logger(__name__, **kwargs) def kernel_abspline3(x, alpha, beta, t1, t2): M = np.array([[1, t1, t1**2, t1**3], @@ -67,6 +69,10 @@ def kernel_abspline3(x, alpha, beta, t1, t2): return r + if not hasattr(G, 'lmax'): + self._logger.info('Has to estimate lmax.') + G.estimate_lmax() + G.lmin = G.lmax / lpfactor if t is None: @@ -79,13 +85,15 @@ def kernel_abspline3(x, alpha, beta, t1, t2): lminfac = .4 * G.lmin - self.g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] + g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] for i in range(0, Nf - 1): - self.g.append(lambda x, ind=i: gb(self.t[ind] * x)) + g.append(lambda x, ind=i: gb(self.t[ind] * x)) f = lambda x: -gb(x) xstar = optimize.minimize_scalar(f, bounds=(1, 2), method='bounded') gamma_l = -f(xstar.x) lminfac = .6 * G.lmin - self.g[0] = lambda x: gamma_l * gl(x / lminfac) + g[0] = lambda x: gamma_l * gl(x / lminfac) + + super(Abspline, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 3c9421af..87b40795 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -26,7 +26,6 @@ class Expwin(Filter): """ def __init__(self, G, bmax=0.2, a=1., **kwargs): - super(Expwin, self).__init__(G, **kwargs) def fx(x, a): y = np.exp(-float(a)/x) @@ -45,4 +44,5 @@ def ffin(x, a): return gx(1 - x, a) g = [lambda x: ffin(np.float64(x)/bmax/G.lmax, a)] - self.g = g + + super(Expwin, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 8e97a509..91abaaeb 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -53,24 +53,16 @@ class Filter(object): """ - def __init__(self, G, filters=None, **kwargs): + def __init__(self, G, filters, **kwargs): - self.logger = utils.build_logger(__name__, **kwargs) - - if not hasattr(G, 'lmax'): - self.logger.info('{} : has to compute lmax'.format( - self.__class__.__name__)) - G.estimate_lmax() + self._logger = utils.build_logger(__name__, **kwargs) self.G = G - if filters: - if isinstance(filters, list): - self.g = filters - else: - self.g = [filters] + if isinstance(filters, list): + self.g = filters else: - self.g = [] + self.g = [filters] def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, **kwargs): @@ -108,11 +100,11 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, """ if not method: method = 'exact' if hasattr(self.G, 'U') else 'cheby' - self.logger.info('The analysis method is {}'.format(method)) + self._logger.info('The analysis method is {}'.format(method)) if method == 'cheby': # Chebyshev approx if not hasattr(self.G, 'lmax'): - self.logger.info('Computing lmax.') + self._logger.info('Has to estimate lmax.') self.G.estimate_lmax() cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) @@ -124,7 +116,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, elif method == 'exact': # Exact computation if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self.logger.info('Computing the Fourier matrix.') + self._logger.info('Has to compute the Fourier matrix.') self.G.compute_fourier_basis() Nf = len(self.g) # nb of filters @@ -251,7 +243,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): if method == 'exact': if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self.logger.info('Computing the Fourier matrix.') + self._logger.info('Has to compute the Fourier matrix.') self.G.compute_fourier_basis() fie = self.evaluate(self.G.e) @@ -270,7 +262,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): elif method == 'cheby': if not hasattr(self.G, 'lmax'): - self.logger.info('Computing lmax.') + self._logger.info('Has to estimate lmax.') self.G.estimate_lmax() cheb_coeffs = approximations.compute_cheby_coeff( @@ -343,8 +335,7 @@ def filterbank_bounds(self, N=999, bounds=None): else: if not hasattr(self.G, 'e'): - self.logger.info( - 'FILTERBANK_BOUNDS: Has to compute Fourier basis.') + self._logger.info('Has to compute Fourier basis.') self.G.compute_fourier_basis() rng = self.G.e @@ -376,7 +367,7 @@ def filterbank_matrix(self): N = self.G.N if N > 2000: - self.logger.warning( + self._logger.warning( 'Creating a big matrix, you can use other methods.') Nf = len(self.g) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 5bc78b4c..2576585d 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -29,7 +29,6 @@ class Gabor(Filter): """ def __init__(self, G, k, **kwargs): - super(Gabor, self).__init__(G, **kwargs) if not hasattr(G, 'e'): self.logger.info('Filter Gabor will calculate and set' @@ -38,6 +37,8 @@ def __init__(self, G, k, **kwargs): Nf = np.shape(G.e)[0] - self.g = [] + g = [] for i in range(Nf): - self.g.append(lambda x, ii=i: k(x - G.e[ii])) + g.append(lambda x, ii=i: k(x - G.e[ii])) + + super(Gabor, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index dc809cb1..22cb0c14 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -24,7 +24,6 @@ class HalfCosine(Filter): """ def __init__(self, G, Nf=6, **kwargs): - super(HalfCosine, self).__init__(G, **kwargs) if Nf <= 2: raise ValueError('The number of filters must be higher than 2.') @@ -38,4 +37,4 @@ def __init__(self, G, Nf=6, **kwargs): for i in range(Nf): g.append(lambda x, ind=i: main_window(x - dila_fact/3. * (ind - 2))) - self.g = g + super(HalfCosine, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 5f1f9783..66c8ed0d 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -28,7 +28,6 @@ class Heat(Filter): """ def __init__(self, G, tau=10, normalize=False, **kwargs): - super(Heat, self).__init__(G, **kwargs) g = [] @@ -58,4 +57,4 @@ def gu(x): else: g.append(lambda x: np.exp(-tau * x/G.lmax)) - self.g = g + super(Heat, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index 94fa9610..cd28bc41 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -38,14 +38,11 @@ class Held(Filter): """ def __init__(self, G, a=2./3, **kwargs): - super(Held, self).__init__(G, **kwargs) g = [lambda x: held(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - (held(x * (2./G.lmax), a)) ** 2))) - self.g = g - def held(val, a): y = np.empty(np.shape(val)) l1 = a @@ -61,3 +58,5 @@ def held(val, a): y[r3ind] = 0 return y + + super(Held, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 668818ff..589ff214 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -28,7 +28,6 @@ class Itersine(Filter): """ def __init__(self, G, Nf=6, overlap=2., **kwargs): - super(Itersine, self).__init__(G, **kwargs) def k(x): return np.sin(0.5*np.pi*np.power(np.cos(x*np.pi), 2)) * ((x >= -0.5)*(x <= 0.5)) @@ -39,4 +38,4 @@ def k(x): for i in range(1, Nf + 1): g.append(lambda x, ind=i: k(x/scale - (ind - overlap/2.)/overlap) / np.sqrt(overlap)*np.sqrt(2)) - self.g = g + super(Itersine, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 5d305618..bb814b22 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -35,7 +35,6 @@ class MexicanHat(Filter): def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, **kwargs): - super(MexicanHat, self).__init__(G, **kwargs) if t is None: G.lmin = G.lmax / lpfactor @@ -57,4 +56,4 @@ def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, else: g.append(lambda x, ind=i: gb(self.t[ind] * x)) - self.g = g + super(MexicanHat, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 8e752b6a..9deb0612 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -3,6 +3,7 @@ import numpy as np from . import Filter +from pygsp import utils class Meyer(Filter): @@ -24,14 +25,15 @@ class Meyer(Filter): """ def __init__(self, G, Nf=6, **kwargs): - super(Meyer, self).__init__(G, **kwargs) + + self._logger = utils.build_logger(__name__, **kwargs) if not hasattr(G, 't'): G.t = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) if len(G.t) >= Nf - 1: - self.logger.warning('You have specified more scales than' - ' the number of scales minus 1') + self._logger.warning('You have specified more scales than ' + 'the number of scales minus 1') t = G.t @@ -39,8 +41,6 @@ def __init__(self, G, Nf=6, **kwargs): for i in range(Nf - 1): g.append(lambda x, ind=i: kernel_meyer(t[ind] * x, 'wavelet')) - self.g = g - def kernel_meyer(x, kerneltype): r""" Evaluates Meyer function and scaling function @@ -81,3 +81,5 @@ def kernel_meyer(x, kerneltype): raise TypeError('Unknown kernel type ', kerneltype) return r + + super(Meyer, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index faa1f8b6..f3ccc30e 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -33,14 +33,11 @@ class Papadakis(Filter): """ def __init__(self, G, a=0.75, **kwargs): - super(Papadakis, self).__init__(G, **kwargs) g = [lambda x: papadakis(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - (papadakis(x*(2./G.lmax), a)) ** 2))) - self.g = g - def papadakis(val, a): y = np.empty(np.shape(val)) l1 = a @@ -55,3 +52,5 @@ def papadakis(val, a): y[r3ind] = 0 return y + + super(Papadakis, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index f78e655d..f14784e4 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -41,14 +41,11 @@ class Regular(Filter): """ def __init__(self, G, d=3, **kwargs): - super(Regular, self).__init__(G, **kwargs) g = [lambda x: regular(x * (2./G.lmax), d)] g.append(lambda x: np.real(np.sqrt(1 - (regular(x * (2./G.lmax), d)) ** 2))) - self.g = g - def regular(val, d): if d == 0: return np.sin(np.pi / 4.*val) @@ -59,3 +56,5 @@ def regular(val, d): output = np.sin(np.pi*output / 2.) return np.sin(np.pi / 4.*(1 + output)) + + super(Regular, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 594ab158..311e8180 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -35,15 +35,12 @@ class Simoncelli(Filter): """ def __init__(self, G, a=2./3, **kwargs): - super(Simoncelli, self).__init__(G, **kwargs) g = [lambda x: simoncelli(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - (simoncelli(x*(2./G.lmax), a)) ** 2))) - self.g = g - def simoncelli(val, a): y = np.empty(np.shape(val)) l1 = a @@ -58,3 +55,5 @@ def simoncelli(val, a): y[r3ind] = 0 return y + + super(Simoncelli, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletf.py index d448cb0c..219133c6 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletf.py @@ -27,7 +27,6 @@ class SimpleTf(Filter): """ def __init__(self, G, Nf=6, t=None, **kwargs): - super(SimpleTf, self).__init__(G, **kwargs) def kernel_simple_tf(x, kerneltype): r""" @@ -79,4 +78,4 @@ def kernel_simple_tf(x, kerneltype): for i in range(Nf - 1): g.append(lambda x, ind=i: kernel_simple_tf(t[ind] * x, 'wavelet')) - self.g = g + super(SimpleTf, self).__init__(G, g, **kwargs) diff --git a/pygsp/filters/warpedtranslates.py b/pygsp/filters/warpedtranslates.py index 34239e3e..a73e0628 100644 --- a/pygsp/filters/warpedtranslates.py +++ b/pygsp/filters/warpedtranslates.py @@ -29,5 +29,4 @@ class WarpedTranslates(Filter): """ def __init__(self, G, Nf=6, **kwargs): - super(WarpedTranslates, self).__init__(G, **kwargs) raise NotImplementedError From 30a4d84485d04f78afc9299c393d8f8e15ecf335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 13:54:12 +0200 Subject: [PATCH 145/392] test: check filters are just referenced --- pygsp/tests/test_filters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index c7c1bee3..92e9dc25 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -26,9 +26,6 @@ def setUpClass(cls): def tearDownClass(cls): pass - def _filter(self, x): - return x / (1. + x) - def _test_synthesis(self, f): Nf = len(f.g) if 1 < Nf < 10: @@ -61,7 +58,10 @@ def _test_methods(self, f): self.assertRaises(NotImplementedError, f.tighten) def test_custom_filter(self): - f = filters.Filter(self._G, filters=self._filter) + def _filter(x): + return x / (1. + x) + f = filters.Filter(self._G, filters=_filter) + self.assertIs(f.g[0], _filter) self._test_methods(f) def test_abspline(self): @@ -69,7 +69,7 @@ def test_abspline(self): self._test_methods(f) def test_gabor(self): - f = filters.Gabor(self._G, self._filter) + f = filters.Gabor(self._G, lambda x: x / (1. + x)) self._test_methods(f) def test_halfcosine(self): From ec28f3c14197907b378d6d813cc9dc9114842fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 16:45:00 +0200 Subject: [PATCH 146/392] lmax as a property of graphs (much simple to handle) --- pygsp/features.py | 5 ----- pygsp/filters/abspline.py | 7 ------- pygsp/filters/approximations.py | 12 ------------ pygsp/filters/filter.py | 8 -------- pygsp/graphs/graph.py | 30 +++++++++++++++++++++--------- pygsp/operators/reduction.py | 6 ++---- 6 files changed, 23 insertions(+), 45 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 86e4d95e..eb3c8113 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -88,11 +88,6 @@ def compute_spectrogram(G, atom=None, M=100, method=None, **kwargs): Number of samples on the spectral scale. (default = 100) """ - from pygsp.filters import Filter - - if not hasattr(G, 'lmax'): - G.estimate_lmax() - if not atom or not hasattr(atom, '__call__'): def atom(x): return np.exp(-M * (x / G.lmax)**2) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 067d44e3..efee1194 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -4,7 +4,6 @@ from scipy import optimize from . import Filter -from pygsp import utils class Abspline(Filter): @@ -34,8 +33,6 @@ class Abspline(Filter): def __init__(self, G, Nf=6, lpfactor=20, t=None, **kwargs): - self._logger = utils.build_logger(__name__, **kwargs) - def kernel_abspline3(x, alpha, beta, t1, t2): M = np.array([[1, t1, t1**2, t1**3], [1, t2, t2**2, t2**3], @@ -69,10 +66,6 @@ def kernel_abspline3(x, alpha, beta, t1, t2): return r - if not hasattr(G, 'lmax'): - self._logger.info('Has to estimate lmax.') - G.estimate_lmax() - G.lmin = G.lmax / lpfactor if t is None: diff --git a/pygsp/filters/approximations.py b/pygsp/filters/approximations.py index 28c2a0cd..89ef6d69 100644 --- a/pygsp/filters/approximations.py +++ b/pygsp/filters/approximations.py @@ -39,10 +39,6 @@ def compute_cheby_coeff(f, m=30, N=None, *args, **kwargs): if not N: N = m + 1 - if not hasattr(G, 'lmax'): - logger.info('The variable lmax has not been computed yet, it will be done now.') - G.estimate_lmax() - a_arange = [0, G.lmax] a1 = (a_arange[1] - a_arange[0]) / 2 @@ -86,10 +82,6 @@ def cheby_op(G, c, signal, **kwargs): if M < 2: raise TypeError("The coefficients have an invalid shape") - if not hasattr(G, 'lmax'): - logger.info('The variable lmax has not been computed yet, it will be done now.') - G.estimate_lmax() - # thanks to that, we can also have 1d signal. try: Nv = np.shape(signal)[1] @@ -146,10 +138,6 @@ def cheby_rect(G, bounds, signal, **kwargs): bounds = np.array(bounds) - if not hasattr(G, 'lmax'): - logger.info('The variable lmax has not been computed yet, it will be done now.') - G.estimate_lmax() - m = int(kwargs.pop('order', 30) + 1) try: diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 91abaaeb..855354b9 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -103,10 +103,6 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, self._logger.info('The analysis method is {}'.format(method)) if method == 'cheby': # Chebyshev approx - if not hasattr(self.G, 'lmax'): - self._logger.info('Has to estimate lmax.') - self.G.estimate_lmax() - cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) c = approximations.cheby_op(self.G, cheb_coef, s) @@ -261,10 +257,6 @@ def synthesis(self, c, order=30, method=None, **kwargs): s += igft(np.conjugate(self.G.U), fc) elif method == 'cheby': - if not hasattr(self.G, 'lmax'): - self._logger.info('Has to estimate lmax.') - self.G.estimate_lmax() - cheb_coeffs = approximations.compute_cheby_coeff( self, m=order, N=order + 1) s = np.zeros((N, np.shape(c)[1])) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d027a5e1..83f8a3cf 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -560,7 +560,7 @@ def extract_components(self): def compute_fourier_basis(self, smallest_first=True, recompute=False, **kwargs): r""" - Compute the fourier basis of the graph. + Compute the Fourier basis of the graph. Parameters ---------- @@ -614,7 +614,7 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, inds = inds[::-1] self.e = np.sort(eigenvalues) - self.lmax = np.max(self.e) + self._lmax = np.max(self.e) self.U = eigenvectors[:, inds] self.mu = np.max(np.abs(self.U)) @@ -660,17 +660,29 @@ def create_laplacian(self, lap_type='combinatorial'): self.L = L + @property + def lmax(self): + r""" + Largest eigenvalue of the graph Laplacian. Can be exactly computed by + :func:`compute_fourier_basis` or approximated by :func:`estimate_lmax`. + """ + try: + return self._lmax + except AttributeError: + self.logger.warning('Need to estimate lmax.') + return self.estimate_lmax() + def estimate_lmax(self, recompute=False): r""" - Estimate the maximal eigenvalue. + Estimate the largest eigenvalue. - Exact value given by the eigendecomposition of the Laplacia, see + Exact value given by the eigendecomposition of the Laplacian, see :func:`compute_fourier_basis`. Parameters ---------- recompute : boolean - Force to recompute the maximal eigenvalue. Default is false. + Force to recompute the largest eigenvalue. Default is false. Returns ------- @@ -687,8 +699,8 @@ def estimate_lmax(self, recompute=False): 41.59 """ - if hasattr(self, 'lmax') and not recompute: - return self.lmax + if hasattr(self, '_lmax') and not recompute: + return self._lmax try: # For robustness purposes, increase the error by 1 percent @@ -701,8 +713,8 @@ def estimate_lmax(self, recompute=False): lmax = 2. * np.max(self.d) lmax = np.real(lmax) - self.lmax = lmax.sum() - return self.lmax + self._lmax = lmax.sum() + return self._lmax def plot(self, **kwargs): r""" diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 0da93c19..7f463ee3 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -231,11 +231,9 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, sparsify_eps = min(10. / np.sqrt(G.N), 0.3) if compute_full_eigen: - if not hasattr(G, 'e') or not hasattr(G, 'U'): - G.compute_fourier_basis() + G.compute_fourier_basis() else: - if not hasattr(G, 'lmax'): - G.estimate_lmax() + G.estimate_lmax() Gs = [G] Gs[0].mr = {'idx': np.arange(G.N), 'orig_idx': np.arange(G.N)} From 677adf2cb2c4054f77e0bb573c810a33d32e5ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 16:54:14 +0200 Subject: [PATCH 147/392] mexicanhat: fix bug --- pygsp/filters/mexicanhat.py | 2 +- pygsp/tests/test_filters.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index bb814b22..36d233e6 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -51,7 +51,7 @@ def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, for i in range(Nf - 1): if normalize: - g.append(lambda x, ind=i: np.sqrt(t[ind]) * + g.append(lambda x, ind=i: np.sqrt(self.t[ind]) * gb(self.t[ind] * x)) else: g.append(lambda x, ind=i: gb(self.t[ind] * x)) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 92e9dc25..f21c1a11 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -53,6 +53,8 @@ def _test_methods(self, f): f.wlog_scales(1, 10, 10) + # TODO: f.can_dual() + self.assertRaises(NotImplementedError, f.approx, 0, 0) self.assertRaises(NotImplementedError, f.inverse, 0) self.assertRaises(NotImplementedError, f.tighten) @@ -81,9 +83,9 @@ def test_itersine(self): self._test_methods(f) def test_mexicanhat(self): - f = filters.MexicanHat(self._G, Nf=5) + f = filters.MexicanHat(self._G, Nf=5, normalize=False) self._test_methods(f) - f = filters.MexicanHat(self._G, Nf=4) + f = filters.MexicanHat(self._G, Nf=4, normalize=True) self._test_methods(f) def test_meyer(self): From 43b9a62ba2929b13eda395553d8ca5dd02b60312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 16:54:46 +0200 Subject: [PATCH 148/392] filters: logger at module level --- pygsp/filters/approximations.py | 12 ++++++------ pygsp/filters/filter.py | 18 +++++++++--------- pygsp/filters/gabor.py | 8 ++++++-- pygsp/filters/heat.py | 8 ++++++-- pygsp/filters/meyer.py | 9 +++++---- pygsp/filters/simpletf.py | 8 ++++++-- 6 files changed, 38 insertions(+), 25 deletions(-) diff --git a/pygsp/filters/approximations.py b/pygsp/filters/approximations.py index 89ef6d69..2d41111c 100644 --- a/pygsp/filters/approximations.py +++ b/pygsp/filters/approximations.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- -from ..utils import filterbank_handler, build_logger - import numpy as np import scipy as sp -logger = build_logger(__name__) +from pygsp import utils + +_logger = utils.build_logger(__name__) -@filterbank_handler +@utils.filterbank_handler def compute_cheby_coeff(f, m=30, N=None, *args, **kwargs): r""" Compute Chebyshev coefficients for a Filterbank. @@ -186,10 +186,10 @@ def compute_jackson_cheby_coeff(filter_bounds, delta_lambda, m): """ # Parameters check if delta_lambda[0] > filter_bounds[0] or delta_lambda[1] < filter_bounds[1]: - logger.error("Bounds of the filter are out of the lambda values") + _logger.error("Bounds of the filter are out of the lambda values") raise() elif delta_lambda[0] > delta_lambda[1]: - logger.error("lambda_min is greater than lambda_max") + _logger.error("lambda_min is greater than lambda_max") raise() # Scaling and translating to standard cheby interval diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 855354b9..5f082c9b 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -7,11 +7,14 @@ import numpy as np -from .. import utils +from pygsp import utils from ..operators.transforms import gft, igft from . import approximations +_logger = utils.build_logger(__name__) + + class Filter(object): r""" The base Filter class. @@ -55,8 +58,6 @@ class Filter(object): def __init__(self, G, filters, **kwargs): - self._logger = utils.build_logger(__name__, **kwargs) - self.G = G if isinstance(filters, list): @@ -100,7 +101,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, """ if not method: method = 'exact' if hasattr(self.G, 'U') else 'cheby' - self._logger.info('The analysis method is {}'.format(method)) + _logger.info('The analysis method is {}'.format(method)) if method == 'cheby': # Chebyshev approx cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) @@ -112,7 +113,7 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, elif method == 'exact': # Exact computation if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self._logger.info('Has to compute the Fourier matrix.') + _logger.warning('Has to compute the Fourier basis.') self.G.compute_fourier_basis() Nf = len(self.g) # nb of filters @@ -239,7 +240,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): if method == 'exact': if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - self._logger.info('Has to compute the Fourier matrix.') + _logger.warning('Has to compute the Fourier basis.') self.G.compute_fourier_basis() fie = self.evaluate(self.G.e) @@ -327,7 +328,7 @@ def filterbank_bounds(self, N=999, bounds=None): else: if not hasattr(self.G, 'e'): - self._logger.info('Has to compute Fourier basis.') + _logger.warning('Has to compute the Fourier basis.') self.G.compute_fourier_basis() rng = self.G.e @@ -359,8 +360,7 @@ def filterbank_matrix(self): N = self.G.N if N > 2000: - self._logger.warning( - 'Creating a big matrix, you can use other methods.') + _logger.warning('Creating a big matrix, you can use other means.') Nf = len(self.g) Ft = self.analysis(np.identity(N)) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 2576585d..bc7542aa 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -3,6 +3,10 @@ import numpy as np from . import Filter +from pygsp import utils + + +_logger = utils.build_logger(__name__) class Gabor(Filter): @@ -31,8 +35,8 @@ class Gabor(Filter): def __init__(self, G, k, **kwargs): if not hasattr(G, 'e'): - self.logger.info('Filter Gabor will calculate and set' - ' the eigenvalues to normalize the kernel') + _logger.info('Filter Gabor will calculate and set' + ' the eigenvalues to normalize the kernel') G.compute_fourier_basis() Nf = np.shape(G.e)[0] diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 66c8ed0d..ad2bf0ad 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -4,6 +4,10 @@ from numpy import linalg from . import Filter +from pygsp import utils + + +_logger = utils.build_logger(__name__) class Heat(Filter): @@ -33,8 +37,8 @@ def __init__(self, G, tau=10, normalize=False, **kwargs): if normalize: if not hasattr(G, 'e'): - self.logger.info('Filter Heat will calculate and set' - ' the eigenvalues to normalize the kernel') + _logger.info('Filter Heat will calculate and set ' + 'the eigenvalues to normalize the kernel') G.compute_fourier_basis() if isinstance(tau, list): diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 9deb0612..6b598d9e 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -6,6 +6,9 @@ from pygsp import utils +_logger = utils.build_logger(__name__) + + class Meyer(Filter): r""" Meyer filterbank @@ -26,14 +29,12 @@ class Meyer(Filter): def __init__(self, G, Nf=6, **kwargs): - self._logger = utils.build_logger(__name__, **kwargs) - if not hasattr(G, 't'): G.t = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) if len(G.t) >= Nf - 1: - self._logger.warning('You have specified more scales than ' - 'the number of scales minus 1') + _logger.warning('You have specified more scales than ' + 'the number of scales minus 1') t = G.t diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletf.py index 219133c6..843b0861 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletf.py @@ -3,6 +3,10 @@ import numpy as np from . import Filter +from pygsp import utils + + +_logger = utils.build_logger(__name__) class SimpleTf(Filter): @@ -70,8 +74,8 @@ def kernel_simple_tf(x, kerneltype): t = (1./(2.*G.lmax) * np.power(2, np.arange(Nf-2, -1, -1))) if len(t) != Nf - 1: - self.logger.warning('You have specified more scales than ' - 'number of filters minus 1.') + _logger.warning('You have specified more scales than ' + 'number of filters minus 1.') g = [lambda x: kernel_simple_tf(t[0] * x, 'sf')] From 506d3490384bec569b1930bc3cb9af867face788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 17:12:09 +0200 Subject: [PATCH 149/392] filters.Filter.wlog_scales --> utils.compute_log_scales --- pygsp/filters/abspline.py | 17 +++++++++-------- pygsp/filters/filter.py | 28 ---------------------------- pygsp/filters/mexicanhat.py | 24 +++++++++++++----------- pygsp/tests/test_filters.py | 2 -- pygsp/utils.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index efee1194..dcc941a7 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -4,6 +4,7 @@ from scipy import optimize from . import Filter +from pygsp import utils class Abspline(Filter): @@ -19,9 +20,9 @@ class Abspline(Filter): Low-pass factor lmin=lmax/lpfactor will be used to determine scales, the scaling function will be created to fill the lowpass gap. (default = 20) - t : ndarray - Vector of scale to be used (Initialized by default at - the value of the log scale) + scales : ndarray + Vector of scales to be used. + By default, initialized with :func:`pygsp.utils.compute_log_scales`. Examples -------- @@ -31,7 +32,7 @@ class Abspline(Filter): """ - def __init__(self, G, Nf=6, lpfactor=20, t=None, **kwargs): + def __init__(self, G, Nf=6, lpfactor=20, scales=None, **kwargs): def kernel_abspline3(x, alpha, beta, t1, t2): M = np.array([[1, t1, t1**2, t1**3], @@ -68,10 +69,10 @@ def kernel_abspline3(x, alpha, beta, t1, t2): G.lmin = G.lmax / lpfactor - if t is None: - self.t = self.wlog_scales(G.lmin, G.lmax, Nf - 1) + if scales is None: + self.scales = utils.compute_log_scales(G.lmin, G.lmax, Nf - 1) else: - self.t = t + self.scales = scales gb = lambda x: kernel_abspline3(x, 2, 2, 1, 2) gl = lambda x: np.exp(-np.power(x, 4)) @@ -80,7 +81,7 @@ def kernel_abspline3(x, alpha, beta, t1, t2): g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] for i in range(0, Nf - 1): - g.append(lambda x, ind=i: gb(self.t[ind] * x)) + g.append(lambda x, ind=i: gb(self.scales[ind] * x)) f = lambda x: -gb(x) xstar = optimize.minimize_scalar(f, bounds=(1, 2), diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 5f082c9b..5456c4d5 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -from __future__ import division - from math import log from copy import deepcopy @@ -372,32 +370,6 @@ def filterbank_matrix(self): return F - def wlog_scales(self, lmin, lmax, Nscales, t1=1, t2=2): - r""" - Compute logarithm scales for wavelets - - Parameters - ---------- - lmin : int - Minimum non-zero eigenvalue - lmax : int - Maximum eigenvalue - Nscales : int - Number of scales - - Returns - ------- - s : ndarray - Scale - - """ - smin = t1 / lmax - smax = t2 / lmin - - s = np.exp(np.linspace(log(smax), log(smin), Nscales)) - - return s - def can_dual(self): r""" Creates a dual graph form a given graph diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 36d233e6..7c5b7497 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -3,6 +3,7 @@ import numpy as np from . import Filter +from pygsp import utils class MexicanHat(Filter): @@ -18,9 +19,9 @@ class MexicanHat(Filter): Low-pass factor lmin=lmax/lpfactor will be used to determine scales, the scaling function will be created to fill the lowpass gap. (default = 20) - t : ndarray - Vector of scale to be used (Initialized by default at the value of the - log scale) + scales : ndarray + Vector of scales to be used. + By default, initialized with :func:`pygsp.utils.compute_log_scales`. normalize : bool Wether to normalize the wavelet by the factor/sqrt(t). (default = False) @@ -33,14 +34,15 @@ class MexicanHat(Filter): """ - def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, + def __init__(self, G, Nf=6, lpfactor=20, scales=None, normalize=False, **kwargs): - if t is None: - G.lmin = G.lmax / lpfactor - self.t = self.wlog_scales(G.lmin, G.lmax, Nf - 1) + G.lmin = G.lmax / lpfactor + + if scales is None: + self.scales = utils.compute_log_scales(G.lmin, G.lmax, Nf - 1) else: - self.t = t + self.scales = scales gb = lambda x: x * np.exp(-x) gl = lambda x: np.exp(-np.power(x, 4)) @@ -51,9 +53,9 @@ def __init__(self, G, Nf=6, lpfactor=20, t=None, normalize=False, for i in range(Nf - 1): if normalize: - g.append(lambda x, ind=i: np.sqrt(self.t[ind]) * - gb(self.t[ind] * x)) + g.append(lambda x, ind=i: np.sqrt(self.scales[ind]) * + gb(self.scales[ind] * x)) else: - g.append(lambda x, ind=i: gb(self.t[ind] * x)) + g.append(lambda x, ind=i: gb(self.scales[ind] * x)) super(MexicanHat, self).__init__(G, g, **kwargs) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index f21c1a11..8dc936ec 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -51,8 +51,6 @@ def _test_methods(self, f): f.filterbank_bounds() # f.filterbank_matrix() TODO: too much memory - f.wlog_scales(1, 10, 10) - # TODO: f.can_dual() self.assertRaises(NotImplementedError, f.approx, 0, 0) diff --git a/pygsp/utils.py b/pygsp/utils.py index 3b8ff110..794155e3 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -5,6 +5,8 @@ the package. """ +from __future__ import division + import sys import importlib import logging @@ -301,6 +303,36 @@ def rescale_center(x): return r +def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): + r""" + Compute logarithm scales for wavelets. + + Parameters + ---------- + lmin : float + Smallest non-zero eigenvalue. + lmax : float + Largest eigenvalue, i.e. :py:attr:`~pygsp.graphs.Graph.lmax`. + Nscales : int + Number of scales. + + Returns + ------- + scales : ndarray + List of scales of length Nscales. + + Examples + -------- + >>> from pygsp import utils + >>> utils.compute_log_scales(1, 10, 3) + array([ 2. , 0.4472136, 0.1 ]) + + """ + scale_min = t1 / lmax + scale_max = t2 / lmin + return np.exp(np.linspace(np.log(scale_max), np.log(scale_min), Nscales)) + + def import_modules(names, src, dst): """Import modules in package.""" for name in names: From bfca26a1b5eeb2adbe3d40a68058106aa0d8adf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 18 Aug 2017 17:55:12 +0200 Subject: [PATCH 150/392] graphs: update logging messages --- pygsp/graphs/graph.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 83f8a3cf..b775020b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -142,23 +142,21 @@ def check_weights(self): has_nan_value = False if np.isinf(self.W.sum()): - self.logger.warning("GSP_TEST_WEIGHTS: There is an infinite " - "value in the weight matrix") + 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("GSP_TEST_WEIGHTS: The main diagonal of " - "the weight matrix is not 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("GSP_TEST_WEIGHTS: The weight matrix is " - "not square!") + self.logger.warning('The weight matrix is not square!') is_not_square = True if np.isnan(self.W.sum()): - self.logger.warning("GSP_TEST_WEIGHTS: There is an NaN " - "value in the weight matrix") + self.logger.warning('There is a NaN value in the weight matrix!') has_nan_value = True return {'has_inf_val': has_inf_val, @@ -183,7 +181,7 @@ def update_graph_attr(self, *args, **kwargs): Notes ----- - This method is usefull if you want to give a new weight matrix + This method is useful if you want to give a new weight matrix (W) and compute the adjacency matrix (A) and more again. The valid attributes are ['W', 'A', 'N', 'd', 'Ne', 'gtype', 'directed', 'coords', 'lap_type', 'L', 'plotting'] @@ -669,7 +667,10 @@ def lmax(self): try: return self._lmax except AttributeError: - self.logger.warning('Need to estimate lmax.') + self.logger.warning('Need to estimate lmax. Explicitly call ' + 'G.estimate_lmax() or ' + 'G.compute_fourier_basis() ' + 'once beforehand to suppress the warning.') return self.estimate_lmax() def estimate_lmax(self, recompute=False): @@ -708,8 +709,7 @@ def estimate_lmax(self, recompute=False): sparse.linalg.eigs(self.L, k=1, tol=5e-3, ncv=10)[0][0] except sparse.linalg.ArpackNoConvergence: - self.logger.warning('GSP_ESTIMATE_LMAX: ' - 'Cannot use default method.') + self.logger.warning('Cannot use default method.') lmax = 2. * np.max(self.d) lmax = np.real(lmax) @@ -752,7 +752,7 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], center = np.zeros((1, dim)) if np.shape(center)[1] != dim: - self.logger.error('Spring coordinates : center has wrong size.') + self.logger.error('Spring coordinates: center has wrong size.') center = np.zeros((1, dim)) dom_size = 1. From 21e7451accfc520ac67fc3a59a04663558238047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 01:10:17 +0200 Subject: [PATCH 151/392] graphs: Fourier basis as properties --- pygsp/filters/filter.py | 13 ----- pygsp/filters/gabor.py | 7 +-- pygsp/filters/heat.py | 5 -- pygsp/graphs/graph.py | 89 +++++++++++++++++++++++++---------- pygsp/operators/reduction.py | 2 - pygsp/operators/transforms.py | 21 --------- 6 files changed, 65 insertions(+), 72 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 5456c4d5..2f58cf3c 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -110,10 +110,6 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, # c = approximations.lanczos_op(self, s, order=lanczos_order) elif method == 'exact': # Exact computation - if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - _logger.warning('Has to compute the Fourier basis.') - self.G.compute_fourier_basis() - Nf = len(self.g) # nb of filters N = self.G.N # nb of nodes try: @@ -237,10 +233,6 @@ def synthesis(self, c, order=30, method=None, **kwargs): method = 'cheby' if method == 'exact': - if not hasattr(self.G, 'e') or not hasattr(self.G, 'U'): - _logger.warning('Has to compute the Fourier basis.') - self.G.compute_fourier_basis() - fie = self.evaluate(self.G.e) Nv = np.shape(c)[1] s = np.zeros((N, Nv)) @@ -323,12 +315,7 @@ def filterbank_bounds(self, N=999, bounds=None): if bounds: xmin, xmax = bounds rng = np.linspace(xmin, xmax, N) - else: - if not hasattr(self.G, 'e'): - _logger.warning('Has to compute the Fourier basis.') - self.G.compute_fourier_basis() - rng = self.G.e sum_filters = np.sum(np.abs(np.power(self.evaluate(rng), 2)), axis=0) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index bc7542aa..28e49bd3 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -34,12 +34,7 @@ class Gabor(Filter): """ def __init__(self, G, k, **kwargs): - if not hasattr(G, 'e'): - _logger.info('Filter Gabor will calculate and set' - ' the eigenvalues to normalize the kernel') - G.compute_fourier_basis() - - Nf = np.shape(G.e)[0] + Nf = G.e.shape[0] g = [] for i in range(Nf): diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index ad2bf0ad..11555361 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -36,11 +36,6 @@ def __init__(self, G, tau=10, normalize=False, **kwargs): g = [] if normalize: - if not hasattr(G, 'e'): - _logger.info('Filter Heat will calculate and set ' - 'the eigenvalues to normalize the kernel') - G.compute_fourier_basis() - if isinstance(tau, list): for t in tau: def gu(x, taulam=t): diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b775020b..5bdc28be 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -555,11 +555,47 @@ def extract_components(self): return graphs + def _check_fourier_properties(self, name): + if not hasattr(self, '_' + name): + self.logger.warning('G.{} is not available, we need to compute ' + 'the graph Fourier basis. Explicitly call ' + 'G.compute_fourier_basis() once beforehand to ' + 'suppress the warning.'.format(name)) + self.compute_fourier_basis() + return getattr(self, '_' + name) + + @property + def U(self): + r""" + Fourier basis, i.e. the eigenvectors of the Laplacian. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('U') + + @property + def e(self): + r""" + Graph frequencies, i.e. the eigenvalues of the Laplacian. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('e') + + @property + def mu(self): + r""" + Coherence of the Fourier basis. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('mu') + def compute_fourier_basis(self, smallest_first=True, recompute=False, **kwargs): r""" Compute the Fourier basis of the graph. + The result is cached and accessible by the :py:attr:`~U`, + :py:attr:`~e`, :py:attr:`~lmax`, and :py:attr:`~mu` properties. + Parameters ---------- smallest_first: bool @@ -590,12 +626,19 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, Examples -------- >>> from pygsp import graphs - >>> N = 50 - >>> G = graphs.Sensor(N) + >>> G = graphs.Torus() >>> G.compute_fourier_basis() + >>> G.U.shape + (256, 256) + >>> G.e.shape + (256,) + >>> G.lmax == G.e[-1] + True + >>> G.mu < 1 + True """ - if hasattr(self, 'e') and hasattr(self, 'U') and not recompute: + if hasattr(self, '_e') and hasattr(self, '_U') and not recompute: return if self.N > 3000: @@ -611,10 +654,10 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, if not smallest_first: inds = inds[::-1] - self.e = np.sort(eigenvalues) - self._lmax = np.max(self.e) - self.U = eigenvectors[:, inds] - self.mu = np.max(np.abs(self.U)) + self._e = np.sort(eigenvalues) + self._lmax = np.max(self._e) + self._U = eigenvectors[:, inds] + self._mu = np.max(np.abs(self._U)) def create_laplacian(self, lap_type='combinatorial'): r""" @@ -664,19 +707,20 @@ def lmax(self): Largest eigenvalue of the graph Laplacian. Can be exactly computed by :func:`compute_fourier_basis` or approximated by :func:`estimate_lmax`. """ - try: - return self._lmax - except AttributeError: - self.logger.warning('Need to estimate lmax. Explicitly call ' - 'G.estimate_lmax() or ' + if not hasattr(self, '_lmax'): + self.logger.warning('G.lmax is not available, we need to estimate ' + 'it. Explicitly call G.estimate_lmax() or ' 'G.compute_fourier_basis() ' 'once beforehand to suppress the warning.') - return self.estimate_lmax() + self.estimate_lmax() + return self._lmax def estimate_lmax(self, recompute=False): r""" Estimate the largest eigenvalue. + The result is cached and accessible by the :py:attr:`~lmax` property. + Exact value given by the eigendecomposition of the Laplacian, see :func:`compute_fourier_basis`. @@ -685,23 +729,19 @@ def estimate_lmax(self, recompute=False): recompute : boolean Force to recompute the largest eigenvalue. Default is false. - Returns - ------- - lmax : float - An estimation of the largest eigenvalue. - Examples -------- >>> from pygsp import graphs - >>> import numpy as np - >>> W = np.arange(16).reshape(4, 4) - >>> G = graphs.Graph(W) - >>> print('{:.2f}'.format(G.estimate_lmax())) - 41.59 + >>> G = graphs.Sensor() + >>> G.compute_fourier_basis() + >>> lmax = G.lmax + >>> G.estimate_lmax(recompute=True) + >>> G.lmax > lmax # Upper bound. + True """ if hasattr(self, '_lmax') and not recompute: - return self._lmax + return try: # For robustness purposes, increase the error by 1 percent @@ -714,7 +754,6 @@ def estimate_lmax(self, recompute=False): lmax = np.real(lmax) self._lmax = lmax.sum() - return self._lmax def plot(self, **kwargs): r""" diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 7f463ee3..5764766a 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -612,8 +612,6 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): else: # When the graph is small enough, we can do a full eigendecomposition # and compute the full analysis operator T_a - if not hasattr(G, 'e') or not hasattr(G, 'U'): - G.compute_fourier_basis() H = G.U * sparse.diags(h_filter(G.e), 0) * G.U.T Phi = G.U * sparse.diags(1./(reg_eps + G.e), 0) * G.U.T Ta = np.concatenate((S * H, sparse.eye(G.N) - Phi[:, keep_inds] * spsolve(Phi[np.ix_(keep_inds, keep_inds)], S*H)), axis=0) diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py index 15153a9b..0bbcf2c5 100644 --- a/pygsp/operators/transforms.py +++ b/pygsp/operators/transforms.py @@ -28,11 +28,6 @@ def gft(G, f): from pygsp.graphs import Graph if isinstance(G, Graph): - if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues ' + - 'and the eigenvectors.') - G.compute_fourier_basis() - U = G.U else: U = G @@ -60,12 +55,7 @@ def igft(G, f_hat): from pygsp.graphs import Graph if isinstance(G, Graph): - if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues ' + - 'and the eigenvectors.') - G.compute_fourier_basis() U = G.U - else: U = G @@ -94,10 +84,6 @@ def generalized_wft(G, g, f, lowmemory=True): """ Nf = np.shape(f)[1] - if not hasattr(G, 'U'): - logger.info('Analysis filter has to compute the eigenvalues and the eigenvectors.') - G.compute_fourier_basis() - if isinstance(g, list): g = igft(G, g[0](G.e)) elif hasattr(g, '__call__'): @@ -142,9 +128,6 @@ def gabor_wft(G, f, k): """ from pygsp.filters import Gabor - if not hasattr(G, 'e'): - logger.info('analysis filter has to compute the eigenvalues and the eigenvectors.') - G.compute_fourier_basis() g = Gabor(G, k) C = g.analysis(f) @@ -199,10 +182,6 @@ def ngwft(G, f, g, lowmemory=True): """ - if not hasattr(G, 'U'): - logger.info('analysis filter has to compute the eigenvalues and the eigenvectors.') - G.compute_fourier_basis() - if lowmemory: # Compute the Frame into a big matrix Frame = _ngwft_frame_matrix(G, g) From 48e58f6ef73ebd71754d121520dffa9c94bc949f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 01:14:53 +0200 Subject: [PATCH 152/392] tests: cleanup --- pygsp/tests/test_all.py | 4 ++-- pygsp/tests/test_filters.py | 5 ++--- pygsp/tests/test_graphs.py | 13 ++++++------- pygsp/tests/test_plotting.py | 5 ++--- pygsp/tests/test_utils.py | 5 ++--- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index a626fd8e..e3dd9f0f 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -14,7 +14,7 @@ def gen_recursive_file(root, ext): - for root, dirnames, filenames in os.walk(root): + for root, _, filenames in os.walk(root): for name in filenames: if name.lower().endswith(ext): yield os.path.join(root, name) @@ -31,7 +31,7 @@ def test_docstrings(root, ext): suites.append(test_utils.suite) suites.append(test_docstrings('pygsp', '.py')) suites.append(test_docstrings('.', '.rst')) -suites.append(test_plotting.suite) # TODO: can SIGSEGV if not at the end +suites.append(test_plotting.suite) # TODO: can SIGSEGV if not last suite = unittest.TestSuite(suites) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 8dc936ec..c87cb9e7 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ @@ -13,7 +12,7 @@ from pygsp import graphs, filters -class FunctionsTestCase(unittest.TestCase): +class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -154,4 +153,4 @@ def test_approximations(self): self._signal, method='lanczos') -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 666e2b6b..2058190e 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ @@ -14,7 +13,7 @@ from pygsp import graphs -class FunctionsTestCase(unittest.TestCase): +class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -24,13 +23,13 @@ def setUpClass(cls): def tearDownClass(cls): pass - def test_default_graph(self): + def test_graph(self): W = np.arange(16).reshape(4, 4) G = graphs.Graph(W) - assert np.allclose(G.W.A, W) - assert np.allclose(G.A.A, G.W.A > 0) + np.testing.assert_allclose(G.W.A, W) + np.testing.assert_allclose(G.A.A, G.W.A > 0) self.assertEqual(G.N, 4) - assert np.allclose(G.d, np.array([[3], [4], [4], [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) @@ -172,4 +171,4 @@ def test_grid2dimgpatches(self): graphs.Grid2dImgPatches(img=self._img, patch_shape=(3, 3)) -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index e7dfb196..dc52db22 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ @@ -14,7 +13,7 @@ from pygsp import graphs, plotting -class FunctionsTestCase(unittest.TestCase): +class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -93,4 +92,4 @@ def test_plot_graphs(self): plotting.close_all() -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index 7b8adc83..f20476e4 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """ @@ -14,7 +13,7 @@ from pygsp import utils, graphs -class FunctionsTestCase(unittest.TestCase): +class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -123,4 +122,4 @@ def test_distanz(x, y): # test_distanz(x, y) -suite = unittest.TestLoader().loadTestsFromTestCase(FunctionsTestCase) +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From ff987bc8f1940c37661b334fa684dbfcf4eb86bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 01:15:52 +0200 Subject: [PATCH 153/392] test filters: random signals are more realistic --- pygsp/tests/test_filters.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index c87cb9e7..fb2d022b 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -17,9 +17,8 @@ class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls._G = graphs.Logo() - # Signal is a Kronecker delta at node 83. - cls._signal = np.zeros(cls._G.N) - cls._signal[83] = 1 + rs = np.random.RandomState(42) + cls._signal = rs.uniform(size=cls._G.N) @classmethod def tearDownClass(cls): @@ -143,12 +142,13 @@ def test_approximations(self): Test that the different methods for filter analysis, i.e. 'exact', 'cheby', and 'lanczos', produce the same output. """ + # TODO: synthesis f = filters.Heat(self._G) c_exact = f.analysis(self._signal, method='exact') c_cheby = f.analysis(self._signal, method='cheby') - assert np.allclose(c_exact, c_cheby) + np.testing.assert_allclose(c_exact, c_cheby) self.assertRaises(NotImplementedError, f.analysis, self._signal, method='lanczos') From f4320f62f72fb788f0dce598568e0a3f16d4cfc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 01:17:02 +0200 Subject: [PATCH 154/392] test Fourier transform --- pygsp/tests/test_all.py | 4 +++- pygsp/tests/test_operators.py | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 pygsp/tests/test_operators.py diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index e3dd9f0f..0eff6d4e 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -10,7 +10,8 @@ import unittest import doctest -from . import test_graphs, test_filters, test_utils, test_plotting +from pygsp.tests import test_graphs, test_filters, test_operators +from pygsp.tests import test_utils, test_plotting def gen_recursive_file(root, ext): @@ -28,6 +29,7 @@ def test_docstrings(root, ext): suites = [] suites.append(test_graphs.suite) suites.append(test_filters.suite) +suites.append(test_operators.suite) suites.append(test_utils.suite) suites.append(test_docstrings('pygsp', '.py')) suites.append(test_docstrings('.', '.rst')) diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py new file mode 100644 index 00000000..4fb3a3bc --- /dev/null +++ b/pygsp/tests/test_operators.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- + +""" +Test suite for the operators module of the pygsp package. + +""" + +import unittest + +import numpy as np +from scipy import sparse + +from pygsp import graphs, filters, operators + + +class TestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.G = graphs.Logo() + cls.G.compute_fourier_basis() + + cls.rs = np.random.RandomState(42) + + @classmethod + def tearDownClass(cls): + pass + + def test_fourier_transform(self): + f = self.rs.uniform(size=self.G.N) + f_hat = operators.gft(self.G, f) + f_star = operators.igft(self.G, f_hat) + np.testing.assert_allclose(f, f_star) + + +suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From e725a775b3438ad005318a42522985d9f3122b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 01:22:00 +0200 Subject: [PATCH 155/392] typo --- pygsp/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 794155e3..f5069723 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -204,8 +204,8 @@ def resistance_distance(M): else: if not M.lap_type == 'combinatorial': - logger.info('Compute the combinatorial laplacian for the resitance' - ' distance') + logger.info('Computing the combinatorial laplacian for the ' + 'resistance distance.') M.create_laplacian(lap_type='combinatorial') L = M.L.tocsc() From cfa32c70d0d5ed77692a941244536723867b4079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 02:36:03 +0200 Subject: [PATCH 156/392] graphs: better laplacian computation --- pygsp/graphs/graph.py | 91 ++++++++++++++++++++++++-------------- pygsp/tests/test_graphs.py | 10 ++--- pygsp/utils.py | 2 +- 3 files changed, 64 insertions(+), 39 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5bdc28be..b43f634e 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -58,7 +58,7 @@ class Graph(object): the graph Laplacian, an N-by-N matrix computed from W. lap_type : 'none', 'normalized', 'combinatorial' determines which kind of Laplacian will be computed by - :func:`create_laplacian`. + :func:`compute_laplacian`. coords : ndarray vertices coordinates in 2D or 3D space. Used for plotting only. Default is None. @@ -80,7 +80,7 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.logger = build_logger(__name__, **kwargs) if len(W.shape) != 2 or W.shape[0] != W.shape[1]: - self.logger.error('W has incorrect shape {}'.format(W.shape)) + raise ValueError('W has incorrect shape {}'.format(W.shape)) self.N = W.shape[0] self.W = sparse.lil_matrix(W) @@ -90,7 +90,8 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.Ne = self.W.nnz self.d = self.A.sum(axis=1) self.gtype = gtype - self.lap_type = lap_type + + self.compute_laplacian(lap_type) if coords is not None: self.coords = coords @@ -100,8 +101,6 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', if not self.is_connected(): self.logger.warning('Graph is not connected!') - self.create_laplacian(lap_type) - self.plotting = {'vertex_size': 10, 'edge_width': 1, 'edge_style': '-', 'vertex_color': 'b'} self.plotting.update(plotting) @@ -263,8 +262,8 @@ def copy_graph_attributes(self, Gn, ctype=True): del Gn.plotting['limits'] if hasattr(self, 'lap_type'): - Gn.lap_type = self.lap_type - Gn.create_laplacian() + Gn.compute_laplacian(self.lap_type) + # TODO: an existing Fourier basis should be updated def set_coords(self, kind='spring', **kwargs): r""" @@ -484,7 +483,7 @@ def is_directed(self, recompute=False): if hasattr(self, '_directed') and not recompute: return self._directed - if np.diff(np.shape(self.W))[0]: + if np.diff(self.W.shape)[0]: raise ValueError("Matrix dimensions mismatch, expecting square " "matrix.") @@ -659,47 +658,75 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, self._U = eigenvectors[:, inds] self._mu = np.max(np.abs(self._U)) - def create_laplacian(self, lap_type='combinatorial'): + def compute_laplacian(self, lap_type='combinatorial'): r""" - Create a new graph laplacian. + Compute a graph Laplacian. + + The result is accessible by the L attribute. Parameters ---------- - lap_type : string - The laplacian type to use. Default is 'combinatorial'. Other - possible values are 'none' and 'normalized', which are not yet - implemented for directed graphs. + lap_type : 'combinatorial', 'normalized' + The type of Laplacian to compute. Default is combinatorial. + + Notes + ----- + For undirected graphs, the combinatorial Laplacian is defined as + + .. math:: L = D - W, + + where :math:`W` is the weight matrix and :math:`D` the degree matrix, + and the normalized Laplacian is defined as + + .. math:: L = I - D^{-1/2} W D^{-1/2}, + + where :math:`I` is the identity matrix. + + Examples + -------- + >>> from pygsp import graphs + >>> G = graphs.Sensor(50) + >>> G.L.shape + (50, 50) + >>> + >>> G.compute_laplacian('combinatorial') + >>> G.compute_fourier_basis() + >>> 0 < G.e[0] < 1e-10 # Smallest eigenvalue close to 0. + True + >>> + >>> G.compute_laplacian('normalized') + >>> G.compute_fourier_basis(recompute=True) + >>> 0 < G.e[0] < G.e[-1] < 2 # Spectrum bounded by [0, 2]. + True + >>> G.e[0] < 1e-10 # Smallest eigenvalue close to 0. + True """ - if self.W.shape == (1, 1): - self.L = sparse.lil_matrix(0) - return - if lap_type not in ['combinatorial', 'normalized', 'none']: - raise AttributeError('Unknown laplacian type {}'.format(lap_type)) + if lap_type not in ['combinatorial', 'normalized']: + raise ValueError('Unknown Laplacian type {}'.format(lap_type)) self.lap_type = lap_type if self.is_directed(): + if lap_type == 'combinatorial': - L = 0.5 * (sparse.diags(np.ravel(self.W.sum(0)), 0) + - sparse.diags(np.ravel(self.W.sum(1)), 0) - - self.W - self.W.T).tocsc() + D1 = sparse.diags(np.ravel(self.W.sum(0)), 0) + D2 = sparse.diags(np.ravel(self.W.sum(1)), 0) + self.L = 0.5 * (D1 + D2 - self.W - self.W.T).tocsc() + elif lap_type == 'normalized': raise NotImplementedError('Yet. Ask Nathanael.') - elif lap_type == 'none': - L = sparse.lil_matrix(0) else: + if lap_type == 'combinatorial': - L = (sparse.diags(np.ravel(self.W.sum(1)), 0) - self.W).tocsc() - elif lap_type == 'normalized': - D = sparse.diags( - np.ravel(np.power(self.W.sum(1), -0.5)), 0).tocsc() - L = sparse.identity(self.N) - D * self.W * D - elif lap_type == 'none': - L = sparse.lil_matrix(0) + D = sparse.diags(np.ravel(self.W.sum(1)), 0) + self.L = (D - self.W).tocsc() - self.L = L + elif lap_type == 'normalized': + d = np.power(self.W.sum(1), -0.5) + D = sparse.diags(np.ravel(d), 0).tocsc() + self.L = sparse.identity(self.N) - D * self.W * D @property def lmax(self): diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 2058190e..d96c40e0 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -41,15 +41,13 @@ def test_laplacian(self): G = graphs.StochasticBlockModel(undirected=True) self.assertFalse(G.is_directed()) - G.create_laplacian(lap_type='combinatorial') - G.create_laplacian(lap_type='normalized') - G.create_laplacian(lap_type='none') + G.compute_laplacian(lap_type='combinatorial') + G.compute_laplacian(lap_type='normalized') G = graphs.StochasticBlockModel(undirected=False) self.assertTrue(G.is_directed()) - G.create_laplacian(lap_type='combinatorial') - G.create_laplacian(lap_type='none') - self.assertRaises(NotImplementedError, G.create_laplacian, + G.compute_laplacian(lap_type='combinatorial') + self.assertRaises(NotImplementedError, G.compute_laplacian, lap_type='normalized') def test_nngraph(self): diff --git a/pygsp/utils.py b/pygsp/utils.py index f5069723..aa0bc229 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -206,7 +206,7 @@ def resistance_distance(M): if not M.lap_type == 'combinatorial': logger.info('Computing the combinatorial laplacian for the ' 'resistance distance.') - M.create_laplacian(lap_type='combinatorial') + M.compute_laplacian(lap_type='combinatorial') L = M.L.tocsc() try: From cdfa4ad24f1d1fb5bbf8f275bf262de5124a080c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 03:00:16 +0200 Subject: [PATCH 157/392] merge data_handling into utils --- doc/reference/data_handling.rst | 7 --- doc/reference/index.rst | 1 - pygsp/__init__.py | 2 - pygsp/data_handling.py | 103 -------------------------------- pygsp/operators/difference.py | 7 +-- pygsp/operators/transforms.py | 11 ++-- pygsp/optimization.py | 12 ++-- pygsp/utils.py | 95 +++++++++++++++++++++++++++++ 8 files changed, 108 insertions(+), 130 deletions(-) delete mode 100644 doc/reference/data_handling.rst delete mode 100644 pygsp/data_handling.py diff --git a/doc/reference/data_handling.rst b/doc/reference/data_handling.rst deleted file mode 100644 index a177f6f1..00000000 --- a/doc/reference/data_handling.rst +++ /dev/null @@ -1,7 +0,0 @@ -============= -Data Handling -============= - -.. automodule:: pygsp.data_handling - :members: - :undoc-members: diff --git a/doc/reference/index.rst b/doc/reference/index.rst index 0505f0a8..a963d05f 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -12,6 +12,5 @@ Reference guide operators plotting features - data_handling optimization utils diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 82c79040..837ce074 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -12,7 +12,6 @@ * :mod:`pygsp.plotting` to plot, * :mod:`pygsp.features` to compute features on graphs, -* :mod:`pygsp.data_handling` to manipulate data, * :mod:`pygsp.optimization` to help solving convex optimization problems, * :mod:`pygsp.utils` for various utilities. @@ -26,7 +25,6 @@ 'operators', 'plotting', 'features', - 'data_handling', 'optimization', 'utils', ] diff --git a/pygsp/data_handling.py b/pygsp/data_handling.py deleted file mode 100644 index 7f8bee4f..00000000 --- a/pygsp/data_handling.py +++ /dev/null @@ -1,103 +0,0 @@ -# -*- coding: utf-8 -*- - -r""" -The :mod:`pygsp.data_handling` module implements some functions to manipulate -data which might prove useful when using the toolbox. -""" - -import numpy as np -from scipy import sparse - -def adj2vec(G): - r""" - Prepare the graph for the gradient computation. - - Parameters - ---------- - G : Graph structure - - """ - if G.is_directed(): - raise NotImplementedError("Not implemented yet.") - - else: - v_i, v_j = (sparse.tril(G.W)).nonzero() - weights = G.W[v_i, v_j] - - # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) - G.v_in = v_i - G.v_out = v_j - G.weights = weights - G.Ne = np.shape(v_i)[0] - - # TODO Return vec - - -def mat2vec(d): - r"""Not implemented yet.""" - raise NotImplementedError - - -def repmatline(A, ncol=1, nrow=1): - r""" - Repeat the matrix A in a specific manner. - - Parameters - ---------- - A : ndarray - ncol : int - default is 1 - nrow : int - default is 1 - - Returns - ------- - Ar : ndarray - - Examples - -------- - >>> from pygsp.data_handling import repmatline - >>> import numpy as np - >>> x = np.array([[1, 2], [3, 4]]) - >>> x - array([[1, 2], - [3, 4]]) - >>> repmatline(x, nrow=2, ncol=3) - array([[1, 1, 1, 2, 2, 2], - [1, 1, 1, 2, 2, 2], - [3, 3, 3, 4, 4, 4], - [3, 3, 3, 4, 4, 4]]) - - """ - - if ncol < 1 or nrow < 1: - raise ValueError('The number of lines and rows must be greater or ' - 'equal to one, or you will get an empty array.') - - return np.repeat(np.repeat(A, ncol, axis=1), nrow, axis=0) - - -def vec2mat(d, Nf): - r""" - Vector to matrix transformation. - - Parameters - ---------- - d : ndarray - Data - Nf : int - Number of filters - - Returns - ------- - d : list of ndarray - Data - - """ - if len(np.shape(d)) == 1: - M = np.shape(d)[0] - return np.reshape(d, (M / Nf, Nf), order='F') - - if len(np.shape(d)) == 2: - M, N = np.shape(d) - return np.reshape(d, (M / Nf, Nf, N), order='F') diff --git a/pygsp/operators/difference.py b/pygsp/operators/difference.py index f7d5ce20..1b126178 100644 --- a/pygsp/operators/difference.py +++ b/pygsp/operators/difference.py @@ -3,11 +3,10 @@ import numpy as np from scipy import sparse -from ..utils import build_logger -from ..data_handling import adj2vec +from pygsp import utils -logger = build_logger(__name__) +logger = utils.build_logger(__name__) def div(G, s): @@ -95,7 +94,7 @@ def grad_mat(G): """ if not hasattr(G, 'v_in'): - adj2vec(G) + utils.adj2vec(G) if hasattr(G, 'Diff'): if not sparse.issparse(G.Diff): diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py index 0bbcf2c5..4abb7297 100644 --- a/pygsp/operators/transforms.py +++ b/pygsp/operators/transforms.py @@ -2,11 +2,10 @@ import numpy as np -from ..utils import build_logger -from ..data_handling import vec2mat, repmatline +from pygsp import utils -logger = build_logger(__name__) +logger = utils.build_logger(__name__) def gft(G, f): @@ -131,7 +130,7 @@ def gabor_wft(G, f, k): g = Gabor(G, k) C = g.analysis(f) - C = vec2mat(C, G.N).T + C = utils.vec2mat(C, G.N).T return C @@ -156,7 +155,7 @@ def _gwft_frame_matrix(G, g): ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(G.N)*np.dot(G.U, (np.kron(np.ones((1, G.N)), ghat)*G.U.T)) - F = repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) + F = utils.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) return F @@ -225,7 +224,7 @@ def _ngwft_frame_matrix(G, g): ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(g.N)*np.dot(G.U, (np.kron(np.ones((G.N)), ghat)*G.U.T)) - F = repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) + F = utils.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) # Normalization F /= np.kron((np.ones((G.N)), np.sqrt(np.sum(np.power(np.abs(F), 2), diff --git a/pygsp/optimization.py b/pygsp/optimization.py index 5fa382d9..ad2c9c59 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -5,11 +5,9 @@ graphs. """ -from .data_handling import adj2vec -from .operators.difference import grad, div -from .utils import build_logger +from pygsp import utils, operators -logger = build_logger(__name__) +logger = utils.build_logger(__name__) def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix=True): @@ -68,7 +66,7 @@ def At(x): return x if not hasattr(G, 'v_in'): - adj2vec(G) + utils.adj2vec(G) tight = 0 l1_nu = 2 * G.lmax * nu @@ -81,9 +79,9 @@ def l1_at(x): return G.Diff * At(D.T * x) else: def l1_a(x): - return grad(G, A(x)) + return operators.grad(G, A(x)) def l1_at(x): - return div(G, x) + return operators.div(G, x) pyunlocbox.prox_l1(x, gamma, A=l1_a, At=l1_at, tight=tight, maxit=maxit, verbose=verbose, tol=tol) diff --git a/pygsp/utils.py b/pygsp/utils.py index aa0bc229..f581e5d7 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -333,6 +333,101 @@ def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): return np.exp(np.linspace(np.log(scale_max), np.log(scale_min), Nscales)) +def adj2vec(G): + r""" + Prepare the graph for the gradient computation. + + Parameters + ---------- + G : Graph structure + + """ + if G.is_directed(): + raise NotImplementedError("Not implemented yet.") + + else: + v_i, v_j = (sparse.tril(G.W)).nonzero() + weights = G.W[v_i, v_j] + + # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) + G.v_in = v_i + G.v_out = v_j + G.weights = weights + G.Ne = np.shape(v_i)[0] + + # TODO Return vec + + +def mat2vec(d): + r"""Not implemented yet.""" + raise NotImplementedError + + +def repmatline(A, ncol=1, nrow=1): + r""" + Repeat the matrix A in a specific manner. + + Parameters + ---------- + A : ndarray + ncol : int + default is 1 + nrow : int + default is 1 + + Returns + ------- + Ar : ndarray + + Examples + -------- + >>> from pygsp import utils + >>> import numpy as np + >>> x = np.array([[1, 2], [3, 4]]) + >>> x + array([[1, 2], + [3, 4]]) + >>> utils.repmatline(x, nrow=2, ncol=3) + array([[1, 1, 1, 2, 2, 2], + [1, 1, 1, 2, 2, 2], + [3, 3, 3, 4, 4, 4], + [3, 3, 3, 4, 4, 4]]) + + """ + + if ncol < 1 or nrow < 1: + raise ValueError('The number of lines and rows must be greater or ' + 'equal to one, or you will get an empty array.') + + return np.repeat(np.repeat(A, ncol, axis=1), nrow, axis=0) + + +def vec2mat(d, Nf): + r""" + Vector to matrix transformation. + + Parameters + ---------- + d : ndarray + Data + Nf : int + Number of filters + + Returns + ------- + d : list of ndarray + Data + + """ + if len(np.shape(d)) == 1: + M = np.shape(d)[0] + return np.reshape(d, (M / Nf, Nf), order='F') + + if len(np.shape(d)) == 2: + M, N = np.shape(d) + return np.reshape(d, (M / Nf, Nf, N), order='F') + + def import_modules(names, src, dst): """Import modules in package.""" for name in names: From 008acd405381c38a3edaf41325f9c65482d44872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 03:03:27 +0200 Subject: [PATCH 158/392] no runtime dependancy on pyunlocbox --- pygsp/utils.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index f581e5d7..ce0d6497 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -84,16 +84,6 @@ def inner(*args, **kwargs): return inner -def pyunlocbox_required(func): - - def inner(*args, **kwargs): - try: - import pyunlocbox - except ImportError: - logger.error('Cannot import pyunlocbox') - return func(*args, **kwargs) - - def loadmat(path): r""" Load a matlab data file. From 82f1f81a07c7a6b508b25fb570ff32a7d4c81dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 03:57:18 +0200 Subject: [PATCH 159/392] docstrings: don't import the whole pygsp --- pygsp/graphs/graph.py | 6 +++--- pygsp/operators/reduction.py | 6 +++--- pygsp/utils.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b43f634e..1f673639 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -508,10 +508,10 @@ def extract_components(self): Examples -------- >>> from scipy import sparse - >>> import pygsp + >>> from pygsp import utils, graphs >>> W = sparse.rand(10, 10, 0.2) - >>> W = pygsp.utils.symmetrize(W) - >>> G = pygsp.graphs.Graph(W=W) + >>> W = utils.symmetrize(W) + >>> G = graphs.Graph(W=W) >>> components = G.extract_components() >>> has_sinks = 'sink' in components[0].info >>> sinks_0 = components[0].info['sink'] if has_sinks else [] diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 5764766a..c7389862 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -217,11 +217,11 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, Examples -------- - >>> import pygsp + >>> from pygsp import graphs, operators >>> levels = 5 - >>> G = pygsp.graphs.Sensor(N=512) + >>> G = graphs.Sensor(N=512) >>> G.compute_fourier_basis() - >>> Gs = pygsp.operators.graph_multiresolution(G, levels, sparsify=False) + >>> Gs = operators.graph_multiresolution(G, levels, sparsify=False) >>> for idx in range(levels): ... Gs[idx].plotting['plot_name'] = 'Reduction level: {}'.format(idx) ... Gs[idx].plot() diff --git a/pygsp/utils.py b/pygsp/utils.py index ce0d6497..911d79b6 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -277,9 +277,9 @@ def rescale_center(x): Examples -------- - >>> import pygsp + >>> from pygsp import utils >>> x = np.array([[1, 6], [2, 5], [3, 4]]) - >>> pygsp.utils.rescale_center(x) + >>> utils.rescale_center(x) array([[-1. , 1. ], [-0.6, 0.6], [-0.2, 0.2]]) From 8309c9441ae276afcc881013b6413d3599db4459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 03:57:57 +0200 Subject: [PATCH 160/392] test difference operators --- pygsp/graphs/graph.py | 3 +- pygsp/operators/difference.py | 146 +++++++++++++++++----------------- pygsp/tests/test_graphs.py | 2 +- pygsp/tests/test_operators.py | 20 +++-- pygsp/utils.py | 2 +- 5 files changed, 93 insertions(+), 80 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1f673639..0b9fd43d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -88,7 +88,8 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.A = self.W > 0 self.Ne = self.W.nnz - self.d = self.A.sum(axis=1) + self.d = np.asarray(self.A.sum(axis=1)).squeeze() + assert self.d.ndim == 1 self.gtype = gtype self.compute_laplacian(lap_type) diff --git a/pygsp/operators/difference.py b/pygsp/operators/difference.py index 1b126178..2e256412 100644 --- a/pygsp/operators/difference.py +++ b/pygsp/operators/difference.py @@ -11,86 +11,94 @@ def div(G, s): r""" - Compute Graph divergence of a signal. + Compute the graph divergence of a signal. Parameters ---------- - G : Graph structure + G : Graph s : ndarray - Signal living on the nodes + Signal of length G.Ne/2 living on the edges (non-directed graph). Returns ------- - di : float - The graph divergence + divergence : ndarray + Divergence signal of length G.N living on the nodes. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> s = np.random.normal(size=G.Ne//2) # Symmetric weight matrix. + >>> div = operators.div(G, s) + >>> grad = operators.grad(G, div) """ - if G.Ne != np.shape(s)[0]: - raise ValueError('Signal size is different from the number of edges.') + if G.Ne != 2 * s.shape[0]: + raise ValueError('Signal length should be the number of edges.') D = grad_mat(G) - di = D.T * s - - if s.dtype == 'float32': - di = np.float32(di) - - return di + return D.T * s def grad(G, s): r""" - Compute the Graph gradient. - - Examples - -------- - >>> import pygsp - >>> import numpy as np - >>> G = pygsp.graphs.Logo() - >>> s = np.random.rand(G.N) - >>> grad = pygsp.operators.grad(G, s) + Compute the graph gradient of a signal. Parameters ---------- - G : Graph structure + G : Graph s : ndarray - Signal living on the nodes + Signal of length G.N living on the nodes. Returns ------- - gr : ndarray - Gradient living on the edges + gradient : ndarray + Gradient signal of length G.Ne/2 living on the edges (non-directed + graph). + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> s = np.random.normal(size=G.N) + >>> grad = operators.grad(G, s) + >>> div = operators.div(G, grad) """ - if G.N != np.shape(s)[0]: - raise ValueError('Signal size is different from the number of nodes.') + if G.N != s.shape[0]: + raise ValueError('Signal length should be the number of nodes.') D = grad_mat(G) - gr = D * s - - if s.dtype == 'float32': - gr = np.float32(gr) - - return gr + return D * s def grad_mat(G): r""" - Gradient sparse matrix of the graph G. - - Examples - -------- - >>> import pygsp - >>> G = pygsp.graphs.Logo() - >>> D = pygsp.operators.grad_mat(G) + Compute the gradient sparse matrix of the graph G. Parameters ---------- - G : Graph structure + G : Graph Returns ------- D : ndarray - Gradient sparse matrix + Gradient sparse matrix of size G.Ne/2 x G.N (non-directed graph). + + Examples + -------- + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> operators.grad_mat(G).shape == (G.Ne//2, G.N) + True """ if not hasattr(G, 'v_in'): @@ -99,32 +107,28 @@ def grad_mat(G): if hasattr(G, 'Diff'): if not sparse.issparse(G.Diff): G.Diff = sparse.csc_matrix(G.Diff) - D = G.Diff - + return G.Diff + + n = G.Ne // 2 + Dr = np.concatenate((np.arange(n), np.arange(n))) + Dc = np.ones((2 * n)) + Dc[:n] = G.v_in + Dc[n:] = G.v_out + Dv = np.empty((2 * n)) + + if not hasattr(G, 'lap_type'): + raise ValueError('Graph does not have the lap_type attribute.') + + if G.lap_type == 'combinatorial': + Dv[:n] = np.sqrt(G.weights.toarray()) + Dv[n:] = -Dv[:n] + elif G.lap_type == 'normalized': + Dv[:n] = np.sqrt(G.weights.toarray() / G.d[G.v_in]) + Dv[n:] = -np.sqrt(G.weights.toarray() / G.d[G.v_out]) else: - n = G.Ne - Dr = np.concatenate((np.arange(n), np.arange(n))) - Dc = np.ones((2 * n)) - Dc[:n] = G.v_in - Dc[n:] = G.v_out - Dv = np.ones((2 * n)) - - try: - if G.lap_type == 'combinatorial': - Dv[:n] = np.sqrt(G.weights.toarray()) - Dv[n:] = -Dv[:n] - - elif G.lap_type == 'normalized': - Dv[:n] = np.sqrt(G.weights.toarray() / G.d[G.v_in]) - Dv[n:] = -np.sqrt(G.weights.toarray() / G.d[G.v_out]) - - else: - raise NotImplementedError('grad not implemented yet for ' + - 'this type of graph Laplacian.') - except AttributeError as err: - print('Graph does not have lap_type attribute: ' + str(err)) - - D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, G.N)) - G.Diff = D - - return D + raise ValueError('Unknown lap_type {}'.format(G.lap_type)) + + D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, G.N)) + G.Diff = D + + return G.Diff diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index d96c40e0..06c97627 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -29,7 +29,7 @@ def test_graph(self): 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]])) + 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) diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py index 4fb3a3bc..7a74dd26 100644 --- a/pygsp/tests/test_operators.py +++ b/pygsp/tests/test_operators.py @@ -8,9 +8,8 @@ import unittest import numpy as np -from scipy import sparse -from pygsp import graphs, filters, operators +from pygsp import graphs, operators class TestCase(unittest.TestCase): @@ -20,17 +19,26 @@ def setUpClass(cls): cls.G = graphs.Logo() cls.G.compute_fourier_basis() - cls.rs = np.random.RandomState(42) + rs = np.random.RandomState(42) + cls.signal = rs.uniform(size=cls.G.N) @classmethod def tearDownClass(cls): pass + def test_difference(self): + for lap_type in ['combinatorial', 'normalized']: + G = graphs.Logo(lap_type=lap_type) + grad = operators.grad(G, self.signal) + div = operators.div(G, grad) + + Ls = operators.div(G, operators.grad(G, self.signal)) + np.testing.assert_allclose(Ls, G.L * self.signal) + def test_fourier_transform(self): - f = self.rs.uniform(size=self.G.N) - f_hat = operators.gft(self.G, f) + f_hat = operators.gft(self.G, self.signal) f_star = operators.igft(self.G, f_hat) - np.testing.assert_allclose(f, f_star) + np.testing.assert_allclose(self.signal, f_star) suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/utils.py b/pygsp/utils.py index 911d79b6..8d03767d 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -343,7 +343,7 @@ def adj2vec(G): G.v_in = v_i G.v_out = v_j G.weights = weights - G.Ne = np.shape(v_i)[0] + assert G.Ne == 2 * G.v_in.size == 2 * G.v_out.size # TODO Return vec From 1fd452e7127cfe517e00493c58d0a59c89a756e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 04:57:08 +0200 Subject: [PATCH 161/392] messages: no need to spell the method manually --- pygsp/graphs/barabasialbert.py | 3 +-- pygsp/graphs/community.py | 14 +++++++------- pygsp/graphs/erdosrenyi.py | 8 ++------ 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 5b96d141..ffd16709 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -39,8 +39,7 @@ class BarabasiAlbert(Graph): """ def __init__(self, N=1000, m0=1, m=1, **kwargs): if m > m0: - raise ValueError("GSP_BarabasiAlbert: The parameter m " - "cannot be above m0.") + raise ValueError('Parameter m cannot be above parameter m0.') W = lil_matrix((N, N)) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 9204f1ee..59d74abc 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -66,15 +66,15 @@ def __init__(self, N=256, **kwargs): try: if len(comm_sizes) > 0: if np.sum(comm_sizes) != N: - raise ValueError('GSP_COMMUNITY: The sum of the community sizes has to be equal to N.') + raise ValueError('The sum of the community sizes has to be equal to N.') if len(comm_sizes) != Nc: - raise ValueError('GSP_COMMUNITY: The length of the community sizes has to be equal to Nc.') + raise ValueError('The length of the community sizes has to be equal to Nc.') except TypeError: - raise TypeError("GSP_COMMUNITY: comm_sizes expected to be a list or array, got {}".format(type(comm_sizes))) + raise TypeError('comm_sizes expected to be a list or array, got {}'.format(type(comm_sizes))) if min_comm * Nc > N: - raise ValueError('GSP_COMMUNITY: The constraint on minimum size for communities is unsolvable.') + raise ValueError('The constraint on minimum size for communities is unsolvable.') info = {'node_com': None, 'comm_sizes': None, 'world_rad': None, 'world_density': world_density, 'min_comm': min_comm} @@ -98,17 +98,17 @@ def __init__(self, N=256, **kwargs): comm_density = float(comm_density) comm_density = comm_density if 0. <= comm_density <= 1. else 0.1 info['comm_density'] = comm_density - self.logger.info("GSP_COMMUNITY: Constructed using community density = {}".format(comm_density)) + self.logger.info('Constructed using community density = {}'.format(comm_density)) elif k_neigh: # k-NN among the nodes in the same community (same k for all communities) k_neigh = int(k_neigh) k_neigh = k_neigh if k_neigh > 0 else 10 info['k_neigh'] = k_neigh - self.logger.info("GSP_COMMUNITY: Constructed using K-NN with k = {}".format(k_neigh)) + self.logger.info('Constructed using K-NN with k = {}'.format(k_neigh)) else: # epsilon-NN among the nodes in the same community (same eps for all communities) info['epsilon'] = epsilon - self.logger.info("GSP_COMMUNITY: Constructed using eps-NN with eps = {}".format(epsilon)) + self.logger.info('Constructed using eps-NN with eps = {}'.format(epsilon)) # Coordinates # info['com_coords'] = info['world_rad'] * np.array(list(zip( diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 9eaa8592..09fc97d1 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -38,12 +38,8 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, max_iter=10, **kwargs): self.p = p - if p > 1: - raise ValueError("GSP_ErdosRenyi: The probability p " - "cannot be above 1.") - elif p < 0: - raise ValueError("GSP_ErdosRenyi: The probability p " - "cannot be negative.") + if not 0 < p < 1: + raise ValueError('Probability p should be in [0, 1].') M = int(N * (N-1) if directed else N * (N-1) / 2) nb_elem = int(p * M) From 1122564422dd927f01b754e4a53d4e986ba52a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 14:26:48 +0200 Subject: [PATCH 162/392] differential operator as member of the Graph class, such as the other operators --- pygsp/graphs/graph.py | 100 ++++++++++++++++++++++++++++++++++ pygsp/operators/__init__.py | 6 +- pygsp/operators/difference.py | 67 +---------------------- pygsp/optimization.py | 3 - pygsp/tests/test_graphs.py | 16 ++++++ pygsp/tests/test_operators.py | 8 +-- pygsp/utils.py | 25 --------- 7 files changed, 123 insertions(+), 102 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0b9fd43d..85fbf79a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -783,6 +783,106 @@ def estimate_lmax(self, recompute=False): lmax = np.real(lmax) self._lmax = lmax.sum() + @property + def D(self): + r""" + Difference operator of the graph. + Is computed by :func:`compute_differential_operator`. + """ + if not hasattr(self, '_D'): + self.logger.warning('The difference operator G.D is not ' + 'available, we need to compute it. Explicitly ' + 'call G.compute_differential_operator() ' + 'once beforehand to suppress the warning.') + self.compute_differential_operator() + return self._D + + def compute_differential_operator(self): + r""" + Compute the graph differential operator. + + The differential operator is a matrix such that + + .. math:: L = D^T D, + + where :math:`D` is the differential operator and :math:`L` is the graph + Laplacian. It is used to compute the gradient and the divergence of a + graph signal, see :func:`pygsp.operators.grad` and + :func:`pygsp.operators.div`. + + The result is cached and accessible by the :py:attr:`~D` property. + + Examples + -------- + >>> from pygsp import graphs + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> G.compute_differential_operator() + >>> G.D.shape == (G.Ne//2, G.N) + True + + """ + + v_in, v_out, weights = self.get_edge_list() + + n = len(v_in) + Dr = np.concatenate((np.arange(n), np.arange(n))) + Dc = np.empty(2*n) + Dc[:n] = v_in + Dc[n:] = v_out + Dv = np.empty(2*n) + + if self.lap_type == 'combinatorial': + Dv[:n] = np.sqrt(weights) + Dv[n:] = -Dv[:n] + elif self.lap_type == 'normalized': + Dv[:n] = np.sqrt(weights / self.d[v_in]) + Dv[n:] = -np.sqrt(weights / self.d[v_out]) + else: + raise ValueError('Unknown lap_type {}'.format(self.lap_type)) + + self._D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, self.N)) + + def get_edge_list(self): + r""" + Return an edge list, an alternative representation of the graph. + + The weighted adjacency matrix is the canonical form used in this + package to represent a graph as it is the easiest to work with when + considering spectral methods. + + Returns + ------- + v_in : vector of int + v_out : vector of int + weights : vector of float + + Examples + -------- + >>> from pygsp import graphs + >>> G = graphs.Logo() + >>> v_in, v_out, weights = G.get_edge_list() + >>> v_in.shape, v_out.shape, weights.shape + ((3131,), (3131,), (3131,)) + + """ + + if self.is_directed(): + raise NotImplementedError + + else: + v_in, v_out = sparse.tril(self.W).nonzero() + weights = self.W[v_in, v_out] + weights = weights.toarray().squeeze() + + # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) + + assert v_in.size == v_out.size == weights.size + assert self.Ne == 2 * v_in.size + + return v_in, v_out, weights + def plot(self, **kwargs): r""" Plot the graph. diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index 91087580..737a5cd6 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -5,9 +5,8 @@ **Differential operators** -* :func:`pygsp.operators.grad_mat`: compute the gradient sparse matrix -* :func:`pygsp.operators.grad`: compute the gradient of a signal -* :func:`pygsp.operators.div`: compute the divergence of a signal +* :func:`pygsp.operators.grad`: compute the gradient of a graph signal +* :func:`pygsp.operators.div`: compute the divergence of a graph signal **Transforms** (frequency and vertex-frequency) @@ -39,7 +38,6 @@ from pygsp import utils as _utils _DIFFERENCE = [ - 'grad_mat', 'grad', 'div', ] diff --git a/pygsp/operators/difference.py b/pygsp/operators/difference.py index 2e256412..fb3ba330 100644 --- a/pygsp/operators/difference.py +++ b/pygsp/operators/difference.py @@ -1,8 +1,5 @@ # -*- coding: utf-8 -*- -import numpy as np -from scipy import sparse - from pygsp import utils @@ -38,9 +35,7 @@ def div(G, s): """ if G.Ne != 2 * s.shape[0]: raise ValueError('Signal length should be the number of edges.') - - D = grad_mat(G) - return D.T * s + return G.D.T.dot(s) def grad(G, s): @@ -73,62 +68,4 @@ def grad(G, s): """ if G.N != s.shape[0]: raise ValueError('Signal length should be the number of nodes.') - - D = grad_mat(G) - return D * s - - -def grad_mat(G): - r""" - Compute the gradient sparse matrix of the graph G. - - Parameters - ---------- - G : Graph - - Returns - ------- - D : ndarray - Gradient sparse matrix of size G.Ne/2 x G.N (non-directed graph). - - Examples - -------- - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> G.N, G.Ne - (1130, 6262) - >>> operators.grad_mat(G).shape == (G.Ne//2, G.N) - True - - """ - if not hasattr(G, 'v_in'): - utils.adj2vec(G) - - if hasattr(G, 'Diff'): - if not sparse.issparse(G.Diff): - G.Diff = sparse.csc_matrix(G.Diff) - return G.Diff - - n = G.Ne // 2 - Dr = np.concatenate((np.arange(n), np.arange(n))) - Dc = np.ones((2 * n)) - Dc[:n] = G.v_in - Dc[n:] = G.v_out - Dv = np.empty((2 * n)) - - if not hasattr(G, 'lap_type'): - raise ValueError('Graph does not have the lap_type attribute.') - - if G.lap_type == 'combinatorial': - Dv[:n] = np.sqrt(G.weights.toarray()) - Dv[n:] = -Dv[:n] - elif G.lap_type == 'normalized': - Dv[:n] = np.sqrt(G.weights.toarray() / G.d[G.v_in]) - Dv[n:] = -np.sqrt(G.weights.toarray() / G.d[G.v_out]) - else: - raise ValueError('Unknown lap_type {}'.format(G.lap_type)) - - D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, G.N)) - G.Diff = D - - return G.Diff + return G.D.dot(s) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index ad2c9c59..3d3611f6 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -65,9 +65,6 @@ def A(x): def At(x): return x - if not hasattr(G, 'v_in'): - utils.adj2vec(G) - tight = 0 l1_nu = 2 * G.lmax * nu diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 06c97627..a8ace2b3 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -50,6 +50,22 @@ def test_laplacian(self): self.assertRaises(NotImplementedError, G.compute_laplacian, lap_type='normalized') + def test_edge_list(self): + G = graphs.StochasticBlockModel(undirected=True) + v_in, v_out, weights = G.get_edge_list() + self.assertEqual(G.W[v_in[42], v_out[42]], weights[42]) + + G = graphs.StochasticBlockModel(undirected=False) + self.assertRaises(NotImplementedError, G.get_edge_list) + + def test_differential_operator(self): + G = graphs.StochasticBlockModel(undirected=True) + L = G.D.T.dot(G.D) + np.testing.assert_allclose(L.toarray(), G.L.toarray()) + + G = graphs.StochasticBlockModel(undirected=False) + self.assertRaises(NotImplementedError, G.compute_differential_operator) + def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py index 7a74dd26..e2f6afb7 100644 --- a/pygsp/tests/test_operators.py +++ b/pygsp/tests/test_operators.py @@ -29,11 +29,9 @@ def tearDownClass(cls): def test_difference(self): for lap_type in ['combinatorial', 'normalized']: G = graphs.Logo(lap_type=lap_type) - grad = operators.grad(G, self.signal) - div = operators.div(G, grad) - - Ls = operators.div(G, operators.grad(G, self.signal)) - np.testing.assert_allclose(Ls, G.L * self.signal) + s_grad = operators.grad(G, self.signal) + Ls = operators.div(G, s_grad) + np.testing.assert_allclose(Ls, G.L.dot(self.signal)) def test_fourier_transform(self): f_hat = operators.gft(self.G, self.signal) diff --git a/pygsp/utils.py b/pygsp/utils.py index 8d03767d..efb878d7 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -323,31 +323,6 @@ def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): return np.exp(np.linspace(np.log(scale_max), np.log(scale_min), Nscales)) -def adj2vec(G): - r""" - Prepare the graph for the gradient computation. - - Parameters - ---------- - G : Graph structure - - """ - if G.is_directed(): - raise NotImplementedError("Not implemented yet.") - - else: - v_i, v_j = (sparse.tril(G.W)).nonzero() - weights = G.W[v_i, v_j] - - # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) - G.v_in = v_i - G.v_out = v_j - G.weights = weights - assert G.Ne == 2 * G.v_in.size == 2 * G.v_out.size - - # TODO Return vec - - def mat2vec(d): r"""Not implemented yet.""" raise NotImplementedError From 684b3007072d79155d1af74591a60c7b17b4ab28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 14:28:05 +0200 Subject: [PATCH 163/392] update documentation and logging messages --- pygsp/graphs/graph.py | 68 ++++++++++++++++++++--------------- pygsp/operators/difference.py | 28 +++++++++++---- pygsp/operators/transforms.py | 64 ++++++++++++++++++++++++++------- pygsp/utils.py | 2 +- 4 files changed, 113 insertions(+), 49 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 85fbf79a..91e1363d 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -17,18 +17,27 @@ class Graph(object): * Can be instantiated to construct custom graphs from a weight matrix. * Initialize attributes for derived classes. + The following operators are available as matrices: + + * :py:attr:`W`: weight matrix + * :py:attr:`L`: Laplacian + * :py:attr:`U`: Fourier basis + * :py:attr:`D`: differential operator + Parameters ---------- W : sparse matrix or ndarray - weight matrix which encodes the graph + The weight matrix which encodes the graph. gtype : string - graph type (default is 'unknown') - lap_type : 'none', 'normalized', 'combinatorial' - Laplacian type (default is 'combinatorial') + Graph type, a free-form string to help us recognize the kind of graph + we are dealing with (default is 'unknown'). + 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) + Vertices coordinates (default is None). plotting : dict - plotting parameters + Plotting parameters. perform_checks : bool Whether to check if the graph is connected. Warn if not. @@ -56,9 +65,8 @@ class Graph(object): help sorting the graphs. L : sparse matrix or ndarray the graph Laplacian, an N-by-N matrix computed from W. - lap_type : 'none', 'normalized', 'combinatorial' - determines which kind of Laplacian will be computed by - :func:`compute_laplacian`. + 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. @@ -555,12 +563,12 @@ def extract_components(self): return graphs - def _check_fourier_properties(self, name): + def _check_fourier_properties(self, name, desc): if not hasattr(self, '_' + name): - self.logger.warning('G.{} is not available, we need to compute ' - 'the graph Fourier basis. Explicitly call ' - 'G.compute_fourier_basis() once beforehand to ' - 'suppress the warning.'.format(name)) + self.logger.warning('The {} G.{} is not available, we need to ' + 'compute the Fourier basis. Explicitly call ' + 'G.compute_fourier_basis() once beforehand ' + 'to suppress the warning.'.format(desc, name)) self.compute_fourier_basis() return getattr(self, '_' + name) @@ -570,7 +578,7 @@ def U(self): Fourier basis, i.e. the eigenvectors of the Laplacian. Is computed by :func:`compute_fourier_basis`. """ - return self._check_fourier_properties('U') + return self._check_fourier_properties('U', 'Fourier basis') @property def e(self): @@ -578,7 +586,7 @@ def e(self): Graph frequencies, i.e. the eigenvalues of the Laplacian. Is computed by :func:`compute_fourier_basis`. """ - return self._check_fourier_properties('e') + return self._check_fourier_properties('e', 'eigenvalues vector') @property def mu(self): @@ -586,15 +594,15 @@ def mu(self): Coherence of the Fourier basis. Is computed by :func:`compute_fourier_basis`. """ - return self._check_fourier_properties('mu') + return self._check_fourier_properties('mu', 'Fourier basis coherence') def compute_fourier_basis(self, smallest_first=True, recompute=False, **kwargs): r""" Compute the Fourier basis of the graph. - The result is cached and accessible by the :py:attr:`~U`, - :py:attr:`~e`, :py:attr:`~lmax`, and :py:attr:`~mu` properties. + The result is cached and accessible by the :py:attr:`U`, + :py:attr:`e`, :py:attr:`lmax`, and :py:attr:`mu` properties. Parameters ---------- @@ -607,17 +615,18 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, Notes ----- 'G.compute_fourier_basis()' computes a full eigendecomposition of - the graph Laplacian G.L: + the graph Laplacian :math:`L` such that: - .. math:: {\cal L} = U \Lambda U^* + .. math:: L = U \Lambda U^*, - where :math:`\Lambda` is a diagonal matrix of eigenvalues. + where :math:`\Lambda` is a diagonal matrix of eigenvalues and the + columns of :math:`U` are the eigenvectors. - *G.e* is a column vector of length *G.N* containing the Laplacian + *G.e* is a vector of length *G.N* containing the Laplacian eigenvalues. The largest eigenvalue is stored in *G.lmax*. The eigenvectors are stored as column vectors of *G.U* in the same order that the eigenvalues. Finally, the coherence of the - Fourier basis is in *G.mu*. + Fourier basis is found in *G.mu*. References ---------- @@ -736,8 +745,9 @@ def lmax(self): :func:`compute_fourier_basis` or approximated by :func:`estimate_lmax`. """ if not hasattr(self, '_lmax'): - self.logger.warning('G.lmax is not available, we need to estimate ' - 'it. Explicitly call G.estimate_lmax() or ' + self.logger.warning('The largest eigenvalue G.lmax is not ' + 'available, we need to estimate it. Explicitly ' + 'call G.estimate_lmax() or ' 'G.compute_fourier_basis() ' 'once beforehand to suppress the warning.') self.estimate_lmax() @@ -747,7 +757,7 @@ def estimate_lmax(self, recompute=False): r""" Estimate the largest eigenvalue. - The result is cached and accessible by the :py:attr:`~lmax` property. + The result is cached and accessible by the :py:attr:`lmax` property. Exact value given by the eigendecomposition of the Laplacian, see :func:`compute_fourier_basis`. @@ -790,7 +800,7 @@ def D(self): Is computed by :func:`compute_differential_operator`. """ if not hasattr(self, '_D'): - self.logger.warning('The difference operator G.D is not ' + self.logger.warning('The differential operator G.D is not ' 'available, we need to compute it. Explicitly ' 'call G.compute_differential_operator() ' 'once beforehand to suppress the warning.') @@ -810,7 +820,7 @@ def compute_differential_operator(self): graph signal, see :func:`pygsp.operators.grad` and :func:`pygsp.operators.div`. - The result is cached and accessible by the :py:attr:`~D` property. + The result is cached and accessible by the :py:attr:`D` property. Examples -------- diff --git a/pygsp/operators/difference.py b/pygsp/operators/difference.py index fb3ba330..2faf3682 100644 --- a/pygsp/operators/difference.py +++ b/pygsp/operators/difference.py @@ -10,6 +10,13 @@ def div(G, s): r""" Compute the graph divergence of a signal. + The divergence of a signal :math:`s` is defined as + + .. math:: y = D^T s, + + where :math:`D` is the differential operator + :py:attr:`pygsp.graphs.Graph.D`. + Parameters ---------- G : Graph @@ -18,7 +25,7 @@ def div(G, s): Returns ------- - divergence : ndarray + s_div : ndarray Divergence signal of length G.N living on the nodes. Examples @@ -29,8 +36,8 @@ def div(G, s): >>> G.N, G.Ne (1130, 6262) >>> s = np.random.normal(size=G.Ne//2) # Symmetric weight matrix. - >>> div = operators.div(G, s) - >>> grad = operators.grad(G, div) + >>> s_div = operators.div(G, s) + >>> s_grad = operators.grad(G, s_div) """ if G.Ne != 2 * s.shape[0]: @@ -42,6 +49,13 @@ def grad(G, s): r""" Compute the graph gradient of a signal. + The gradient of a signal :math:`s` is defined as + + .. math:: y = D s, + + where :math:`D` is the differential operator + :py:attr:`pygsp.graphs.Graph.D`. + Parameters ---------- G : Graph @@ -50,7 +64,7 @@ def grad(G, s): Returns ------- - gradient : ndarray + s_grad : ndarray Gradient signal of length G.Ne/2 living on the edges (non-directed graph). @@ -62,8 +76,10 @@ def grad(G, s): >>> G.N, G.Ne (1130, 6262) >>> s = np.random.normal(size=G.N) - >>> grad = operators.grad(G, s) - >>> div = operators.div(G, grad) + >>> s_grad = operators.grad(G, s) + >>> s_div = operators.div(G, s_grad) + >>> np.linalg.norm(s_div - G.L.dot(s)) < 1e-10 + True """ if G.N != s.shape[0]: diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py index 4abb7297..6d43268c 100644 --- a/pygsp/operators/transforms.py +++ b/pygsp/operators/transforms.py @@ -8,20 +8,40 @@ logger = utils.build_logger(__name__) -def gft(G, f): +def gft(G, s): r""" - Compute Graph Fourier transform. + Compute graph Fourier transform. + + The graph Fourier transform of a signal :math:`s` is defined as + + .. math:: \hat{s} = U^* s, + + where :math:`U` is the Fourier basis :py:attr:`pygsp.graphs.Graph.U` and + :math:`U^*` denotes the conjugate transpose or Hermitian transpose of + :math:`U`. Parameters ---------- G : Graph or Fourier basis - f : ndarray - must be in 2d, even if the second dim is 1 signal + s : ndarray + Graph signal in the vertex domain. Returns ------- - f_hat : ndarray - Graph Fourier transform of *f* + s_hat : ndarray + Representation of s in the Fourier domain. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s = np.random.normal(size=G.N) + >>> s_hat = operators.gft(G, s) + >>> s_star = operators.igft(G, s_hat) + >>> np.linalg.norm(s - s_star) < 1e-10 + True + """ from pygsp.graphs import Graph @@ -31,23 +51,41 @@ def gft(G, f): else: U = G - return np.dot(np.conjugate(U.T), f) # True Hermitian here. + return np.dot(np.conjugate(U.T), s) # True Hermitian here. -def igft(G, f_hat): +def igft(G, s_hat): r""" Compute inverse graph Fourier transform. + The inverse graph Fourier transform of a Fourier domain signal + :math:`\hat{s}` is defined as + + .. math:: s = U \hat{s}, + + where :math:`U` is the Fourier basis :py:attr:`pygsp.graphs.Graph.U`. + Parameters ---------- G : Graph or Fourier basis - f_hat : ndarray - Signal + s_hat : ndarray + Graph signal in the Fourier domain. Returns ------- - f : ndarray - Inverse graph Fourier transform of *f_hat* + s : ndarray + Representation of s_hat in the vertex domain. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s_hat = np.random.normal(size=G.N) + >>> s = operators.igft(G, s_hat) + >>> s_hat_star = operators.gft(G, s) + >>> np.linalg.norm(s_hat - s_hat_star) < 1e-10 + True """ @@ -58,7 +96,7 @@ def igft(G, f_hat): else: U = G - return np.dot(U, f_hat) + return np.dot(U, s_hat) def generalized_wft(G, g, f, lowmemory=True): diff --git a/pygsp/utils.py b/pygsp/utils.py index efb878d7..502f0769 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -302,7 +302,7 @@ def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): lmin : float Smallest non-zero eigenvalue. lmax : float - Largest eigenvalue, i.e. :py:attr:`~pygsp.graphs.Graph.lmax`. + Largest eigenvalue, i.e. :py:attr:`pygsp.graphs.Graph.lmax`. Nscales : int Number of scales. From 21c5274f6b001b740a0b6dbbfcae23bf2265dd05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 17:08:19 +0200 Subject: [PATCH 164/392] fix bug in fruchterman_reingold --- pygsp/graphs/graph.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 91e1363d..95ccadec 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -943,19 +943,18 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], else: pos_arr = None - if k is None and fixed is not None: + 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) - if fixed is None: + if len(fixed) == 0: pos = _rescale_layout(pos, scale=scale) + center return pos -def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, - iterations=50): +def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations): # Position nodes in adjacency matrix A using Fruchterman-Reingold nnodes = A.shape[0] @@ -969,10 +968,6 @@ def _sparse_fruchterman_reingold(A, dim=2, k=None, pos=None, fixed=None, # random initial positions pos = np.random.random((nnodes, dim)) - # no fixed nodes - if fixed is None: - fixed = [] - # optimal distance between nodes if k is None: k = np.sqrt(1.0 / nnodes) From c7a7cdeb554cce402bbc7ad42a0b6f4cfe7c628a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 17:14:47 +0200 Subject: [PATCH 165/392] graph.set_coordinates: clean implementation --- pygsp/graphs/graph.py | 68 +++++++++++++++++++++---------------------- pygsp/plotting.py | 4 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 95ccadec..3ac2b9f8 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -274,54 +274,47 @@ def copy_graph_attributes(self, Gn, ctype=True): Gn.compute_laplacian(self.lap_type) # TODO: an existing Fourier basis should be updated - def set_coords(self, kind='spring', **kwargs): + def set_coordinates(self, kind='spring', **kwargs): r""" - Set coordinates for the vertices. + Set the coordinates of the nodes. Used to position them when plotting. Parameters ---------- - kind : string - The kind of display. Default is 'spring'. - Accepting ['community2D', 'manual', 'random2D', 'random3D', - 'ring2D', 'spring']. - coords : np.ndarray - An array of coordinates in 2D or 3D. Used only if kind is manual. - Set the coordinates to this array as is. + 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, spring. Default is 'spring'. + kwargs : dict + Additional parameters to be passed to the Fruchterman-Reingold + force-directed algorithm when kind is spring. Examples -------- >>> from pygsp import graphs >>> G = graphs.ErdosRenyi() - >>> G.set_coords() + >>> G.set_coordinates() >>> G.plot() """ - if kind not in ['community2D', 'manual', 'random2D', 'random3D', - 'ring2D', 'spring']: - raise ValueError('Unexpected kind argument. Got {}.'.format(kind)) - - if kind == 'manual': - coords = kwargs.pop('coords', None) - if isinstance(coords, list): - coords = np.array(coords) - if isinstance(coords, np.ndarray) and len(coords.shape) == 2 and \ - coords.shape[0] == self.N and 2 <= coords.shape[1] <= 3: - self.coords = coords - else: - raise ValueError('Expecting coords to be a list or ndarray ' - 'of size Nx2 or Nx3.') + + if not isinstance(kind, str): + coords = np.asarray(kind) + check_dim = (2 <= coords.shape[1] <= 3) + if coords.ndim != 2 or coords.shape[0] != self.N or not check_dim: + raise ValueError('Expecting coords to be of size Nx2 or Nx3.') + self.coords = coords elif kind == 'ring2D': - tmp = np.arange(self.N).reshape(self.N, 1) - self.coords = np.concatenate((np.cos(tmp * 2 * np.pi / self.N), - np.sin(tmp * 2 * np.pi / self.N)), - axis=1) + angle = np.arange(self.N) * 2 * np.pi / self.N + self.coords = np.stack([np.cos(angle), np.sin(angle)]) elif kind == 'random2D': - self.coords = np.random.rand(self.N, 2) + self.coords = np.random.uniform(size=(self.N, 2)) elif kind == 'random3D': - self.coords = np.random.rand(self.N, 3) + self.coords = np.random.uniform(size=(self.N, 3)) elif kind == 'spring': self.coords = self._fruchterman_reingold_layout(**kwargs) @@ -360,6 +353,9 @@ def set_coords(self, kind='spring', **kwargs): self.coords[i] = self.info['com_coords'][comm_idx] + \ comm_rad * self.coords[i] + else: + raise ValueError('Unexpected argument king={}.'.format(kind)) + def subgraph(self, ind): r""" Create a subgraph from G keeping only the given indices. @@ -911,7 +907,7 @@ def plot_signal(self, signal, **kwargs): from pygsp import plotting plotting.plot_signal(self, signal, **kwargs) - def show_spectrogram(self, **kwargs): + def plot_spectrogram(self, **kwargs): r""" Plot the spectrogram for the graph object. @@ -923,6 +919,7 @@ def show_spectrogram(self, **kwargs): def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], iterations=50, scale=1.0, center=None): # TODO doc + # fixed: list of nodes with fixed coordinates # Position nodes using Fruchterman-Reingold force-directed algorithm. if center is None: @@ -933,6 +930,7 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], center = np.zeros((1, dim)) dom_size = 1. + if pos is not None: # Determine size of existing domain to adjust initial positions dom_size = np.max(pos) @@ -946,11 +944,13 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], 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) + + pos = _sparse_fruchterman_reingold(self.A, dim, k, pos_arr, + fixed, iterations) if len(fixed) == 0: pos = _rescale_layout(pos, scale=scale) + center + return pos @@ -966,7 +966,7 @@ def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations): if pos is None: # random initial positions - pos = np.random.random((nnodes, dim)) + pos = np.random.uniform(size=(nnodes, dim)) # optimal distance between nodes if k is None: diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 592d508d..ebb53f79 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -150,7 +150,7 @@ def plot_graph(G, default_qtg=True, **kwargs): """ if not hasattr(G, 'coords'): raise AttributeError('Graph has no coordinate set. ' - 'Please run G.set_coords() first.') + 'Please run G.set_coordinates() first.') if G.coords.shape[1] not in [2, 3]: raise AttributeError('Coordinates should be in 2 or 3D space.') @@ -517,7 +517,7 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): """ if not hasattr(G, 'coords'): raise AttributeError('Graph has no coordinate set. ' - 'Please run G.set_coords() first.') + 'Please run G.set_coordinates() first.') if qtg_import and (default_qtg or not plt_import): _qtg_plot_signal(G, signal, **kwargs) From 1859c03a7231c32dd6fa731754308429adf72181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 17:15:47 +0200 Subject: [PATCH 166/392] test graph.set_coordinates --- 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 a8ace2b3..ac6099c2 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -66,6 +66,21 @@ def test_differential_operator(self): G = graphs.StochasticBlockModel(undirected=False) self.assertRaises(NotImplementedError, G.compute_differential_operator) + def test_set_coordinates(self): + G = graphs.FullConnected() + coords = np.random.uniform(size=(G.N, 2)) + G.set_coordinates(coords) + G.set_coordinates('ring2D') + G.set_coordinates('random2D') + G.set_coordinates('random3D') + G.set_coordinates('spring') + G.set_coordinates('spring', dim=3) + G.set_coordinates('spring', dim=3, pos=G.coords) + self.assertRaises(AttributeError, G.set_coordinates, 'community2D') + G = graphs.Community() + G.set_coordinates('community2D') + self.assertRaises(ValueError, G.set_coordinates, 'invalid') + def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] From 5daf47ecc2a9b070ccf1d6c23a246feb340aa857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 17:26:57 +0200 Subject: [PATCH 167/392] only the user should change the type of laplacian --- pygsp/operators/reduction.py | 15 +++++++-------- pygsp/utils.py | 16 +++++++--------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index c7389862..2cc12ebe 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -304,20 +304,19 @@ def kron_reduction(G, ind): """ if isinstance(G, Graph): - if hasattr(G, 'lap_type'): - if not G.lap_type == 'combinatorial': - message = 'Unknwon reduction for {} laplacian.'.format(G.lap_type) - raise NotImplementedError(message) + + if G.lap_type != 'combinatorial': + msg = 'Unknown reduction for {} Laplacian.'.format(G.lap_type) + raise NotImplementedError(msg) if G.is_directed(): - message = 'This method only work for undirected graphs.' - raise NotImplementedError(message) + msg = 'This method only work for undirected graphs.' + raise NotImplementedError(msg) - if not hasattr(G, 'L'): - G.compute_laplacian() L = G.L else: + L = G N = np.shape(L)[0] diff --git a/pygsp/utils.py b/pygsp/utils.py index 502f0769..d2c8fcec 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -170,13 +170,13 @@ def distanz(x, y=None): return np.sqrt(d) -def resistance_distance(M): +def resistance_distance(G): r""" Compute the resistance distances of a graph. Parameters ---------- - M : Graph or sparse matrix + G : Graph or sparse matrix Graph structure or Laplacian matrix (L) Returns @@ -189,15 +189,13 @@ def resistance_distance(M): :cite:`klein1993resistance` """ - if sparse.issparse(M): - L = M.tocsc() + if sparse.issparse(G): + L = G.tocsc() else: - if not M.lap_type == 'combinatorial': - logger.info('Computing the combinatorial laplacian for the ' - 'resistance distance.') - M.compute_laplacian(lap_type='combinatorial') - L = M.L.tocsc() + if G.lap_type != 'combinatorial': + raise ValueError('Need a combinatorial Laplacian.') + L = G.L.tocsc() try: pseudo = sparse.linalg.inv(L) From f259f05cd05fb34d0c2575bc3b2305faecd1dfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 19 Aug 2017 17:50:31 +0200 Subject: [PATCH 168/392] lanczos approximation not ready for use --- pygsp/filters/filter.py | 2 +- pygsp/tests/test_filters.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 2f58cf3c..35e8012b 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from math import log from copy import deepcopy import numpy as np @@ -258,6 +257,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): cheb_coeffs[i], c[i * N + tmpN]) elif method == 'lanczos': + raise NotImplementedError s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index fb2d022b..d99c179d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -24,24 +24,30 @@ def setUpClass(cls): def tearDownClass(cls): pass + def _generate_coefficients(self, N, Nf, vertex_delta=83): + S = np.zeros((N*Nf, Nf)) + S[vertex_delta] = 1 + for i in range(Nf): + S[vertex_delta + i * self._G.N, i] = 1 + return S + def _test_synthesis(self, f): Nf = len(f.g) if 1 < Nf < 10: - - vertex_delta = 83 - S = np.zeros((self._G.N * Nf, Nf)) - S[vertex_delta] = 1 - for i in range(Nf): - S[vertex_delta + i * self._G.N, i] = 1 - + S = self._generate_coefficients(f.G.N, Nf) f.synthesis(S, method='cheby') f.synthesis(S, method='exact') + self.assertRaises(NotImplementedError, f.synthesis, S, + method='lanczos') def _test_methods(self, f): self.assertIs(f.G, self._G) f.analysis(self._signal, method='exact') f.analysis(self._signal, method='cheby') + # TODO np.testing.assert_allclose(c_exact, c_cheby) + self.assertRaises(NotImplementedError, f.analysis, + self._signal, method='lanczos') self._test_synthesis(f) f.evaluate(np.ones(10)) From d54a8b2799110a0b97eb106a74545f5a956fb7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 08:32:53 +0200 Subject: [PATCH 169/392] fix random regular graph --- pygsp/graphs/randomregular.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 93b9d81f..5cbfbb00 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -67,7 +67,7 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): # print(progess) if edgesTested % 5000 == 0: self.logger.debug("createRandRegGraph() progress: edges= " - "{}/{}.".format(edgesTested, n*k/2)) + "{}/{}.".format(edgesTested, N*k/2)) # chose at random 2 half edges i1 = np.random.randint(0, np.shape(U)[0]) From 4af195d4a507d0a6ba939046c51e2dde2fd758bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 08:35:28 +0200 Subject: [PATCH 170/392] features.image_patches --> utils.extract_patches --- pygsp/features.py | 67 ----------------------------- pygsp/graphs/nngraphs/imgpatches.py | 4 +- pygsp/utils.py | 67 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index eb3c8113..684d569e 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -10,7 +10,6 @@ from .graphs import Graph from .filters import Filter from .utils import filterbank_handler -from skimage.util import view_as_windows, pad def compute_avg_adj_deg(G): @@ -103,69 +102,3 @@ def atom(x): G.spectr = spectr return spectr - - -def patch_features(img, patch_shape=(3, 3)): - r""" - Compute a patch feature vector for every pixel of an image. - - Parameters - ---------- - img : array - Input image. - patch_shape : tuple, optional - Dimensions of the patch window. Syntax: (height, width), or (height,), - in which case width = height. - - Returns - ------- - array - Feature matrix. - - Notes - ----- - The feature vector of a pixel `i` will consist of the stacking of the - intensity values of all pixels in the patch centered at `i`, for all color - channels. So, if the input image has `d` color channels, the dimension of - the feature vector of each pixel is (patch_shape[0] * patch_shape[1] * d). - - Examples - -------- - >>> from pygsp import features - >>> from skimage import data, img_as_float - >>> img = img_as_float(data.camera()[::2, ::2]) - >>> X = features.patch_features(img) - - """ - - try: - h, w, d = img.shape - except ValueError: - try: - h, w = img.shape - d = 0 - except ValueError: - print("Image should be at least a 2-d array.") - - try: - r, c = patch_shape - except ValueError: - r = patch_shape[0] - c = r - if d == 0: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) - window_shape = (r, c) - d = 1 # For the reshape in the return call - else: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), - (0, 0)) - window_shape = (r, c, d) - # Pad the image - img_pad = pad(img, pad_width=pad_width, mode='symmetric') - - # Extract patches - patches = view_as_windows(img_pad, window_shape=window_shape) - - return patches.reshape((h * w, r * c * d)) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index e45aab0f..bf23c967 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from pygsp.graphs import NNGraph -from pygsp.features import patch_features +from pygsp import utils class ImgPatches(NNGraph): @@ -33,7 +33,7 @@ class ImgPatches(NNGraph): def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, use_flann=True, dist_type='euclidean', symmetrize_type='full', **kwargs): - X = patch_features(img, patch_shape=patch_shape) + X = utils.extract_patches(img, patch_shape=patch_shape) super(ImgPatches, self).__init__(X, use_flann=use_flann, diff --git a/pygsp/utils.py b/pygsp/utils.py index d2c8fcec..85ecf25f 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -18,6 +18,7 @@ from scipy import kron, ones from scipy import sparse from scipy.io import loadmat as scipy_loadmat +import skimage def build_logger(name, **kwargs): @@ -391,6 +392,72 @@ def vec2mat(d, Nf): return np.reshape(d, (M / Nf, Nf, N), order='F') +def extract_patches(img, patch_shape=(3, 3)): + r""" + Extract a patch feature vector for every pixel of an image. + + Parameters + ---------- + img : array + Input image. + patch_shape : tuple, optional + Dimensions of the patch window. Syntax: (height, width), or (height,), + in which case width = height. + + Returns + ------- + array + Feature matrix. + + Notes + ----- + The feature vector of a pixel `i` will consist of the stacking of the + intensity values of all pixels in the patch centered at `i`, for all color + channels. So, if the input image has `d` color channels, the dimension of + the feature vector of each pixel is (patch_shape[0] * patch_shape[1] * d). + + Examples + -------- + >>> from pygsp import utils + >>> import skimage + >>> img = skimage.img_as_float(skimage.data.camera()[::2, ::2]) + >>> X = utils.extract_patches(img) + + """ + + try: + h, w, d = img.shape + except ValueError: + try: + h, w = img.shape + d = 0 + except ValueError: + print("Image should be at least a 2-d array.") + + try: + r, c = patch_shape + except ValueError: + r = patch_shape[0] + c = r + if d == 0: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) + window_shape = (r, c) + d = 1 # For the reshape in the return call + else: + pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), + (0, 0)) + window_shape = (r, c, d) + # Pad the image + img_pad = skimage.util.pad(img, pad_width=pad_width, mode='symmetric') + + # Extract patches + patches = skimage.util.view_as_windows(img_pad, window_shape=window_shape) + + return patches.reshape((h * w, r * c * d)) + + def import_modules(names, src, dst): """Import modules in package.""" for name in names: From 643f30384191032612753d625d697e29b18f7af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 09:35:15 +0200 Subject: [PATCH 171/392] easier to ask for forgiveness than permission --- pygsp/operators/transforms.py | 12 ++++-------- pygsp/plotting.py | 12 ++++-------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py index 6d43268c..6b3ad081 100644 --- a/pygsp/operators/transforms.py +++ b/pygsp/operators/transforms.py @@ -44,11 +44,9 @@ def gft(G, s): """ - from pygsp.graphs import Graph - - if isinstance(G, Graph): + try: U = G.U - else: + except AttributeError: U = G return np.dot(np.conjugate(U.T), s) # True Hermitian here. @@ -89,11 +87,9 @@ def igft(G, s_hat): """ - from pygsp.graphs import Graph - - if isinstance(G, Graph): + try: U = G.U - else: + except AttributeError: U = G return np.dot(U, s_hat) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index ebb53f79..564e76db 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -104,15 +104,11 @@ def plot(O, **kwargs): >>> plotting.plot(G, default_qtg=False) """ - from .graphs import Graph - from .filters import Filter - if issubclass(type(O), Graph): - plot_graph(O, **kwargs) - elif issubclass(type(O), Filter): - plot_filter(O, **kwargs) - else: - raise TypeError('Unrecognized object type, i.e. not Graph or Filter.') + try: + O.plot(**kwargs) + except AttributeError: + raise TypeError('Unrecognized object, i.e. not a Graph or Filter.') def plot_graph(G, default_qtg=True, **kwargs): From aeb2d8ded2e75746e24325965e1f088cf28e5c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 09:36:31 +0200 Subject: [PATCH 172/392] plotting: no more qtg_default=False in docstrings --- pygsp/plotting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 564e76db..241e0091 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -101,7 +101,7 @@ def plot(O, **kwargs): -------- >>> from pygsp import graphs, plotting >>> G = graphs.Logo() - >>> plotting.plot(G, default_qtg=False) + >>> plotting.plot(G) """ @@ -141,7 +141,7 @@ def plot_graph(G, default_qtg=True, **kwargs): -------- >>> from pygsp import graphs, plotting >>> G = graphs.Logo() - >>> plotting.plot_graph(G, default_qtg=False) + >>> plotting.plot_graph(G) """ if not hasattr(G, 'coords'): @@ -508,7 +508,7 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): >>> from pygsp import graphs, filters, plotting >>> G = graphs.Grid2d(4) >>> signal = np.sin((np.arange(16) * 2*np.pi/16)) - >>> plotting.plot_signal(G, signal, default_qtg=False) + >>> plotting.plot_signal(G, signal) """ if not hasattr(G, 'coords'): From 09c7bd081c35b86b1ebb1a2d792dc7bcea3f1740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 10:00:04 +0200 Subject: [PATCH 173/392] utils: normalize imports --- pygsp/utils.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 85ecf25f..df86924a 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -10,14 +10,13 @@ import sys import importlib import logging -from functools import wraps -from pkgutil import get_data -from io import BytesIO +import functools +import pkgutil +import io import numpy as np -from scipy import kron, ones from scipy import sparse -from scipy.io import loadmat as scipy_loadmat +import scipy.io import skimage @@ -61,7 +60,7 @@ def inner(G, *args, **kwargs): def filterbank_handler(func): - @wraps(func) + @functools.wraps(func) def inner(f, *args, **kwargs): if 'i' in kwargs: @@ -102,15 +101,15 @@ def loadmat(path): Examples -------- - >>> from pygsp.utils import loadmat - >>> data = loadmat('pointclouds/bunny') + >>> from pygsp import utils + >>> data = utils.loadmat('pointclouds/bunny') >>> data['bunny'].shape (2503, 3) """ - data = get_data('pygsp', 'data/' + path + '.mat') - data = BytesIO(data) - return scipy_loadmat(data) + data = pkgutil.get_data('pygsp', 'data/' + path + '.mat') + data = io.BytesIO(data) + return scipy.io.loadmat(data) def distanz(x, y=None): @@ -132,9 +131,9 @@ def distanz(x, y=None): Examples -------- >>> import numpy as np - >>> from pygsp.utils import distanz + >>> from pygsp import utils >>> x = np.arange(3) - >>> distanz(x, x) + >>> utils.distanz(x, x) array([[ 0., 1., 2.], [ 1., 0., 1.], [ 2., 1., 0.]]) @@ -165,8 +164,8 @@ def distanz(x, y=None): yy = (y * y).sum(axis=0) xy = np.dot(x.T, y) - d = abs(kron(ones((cy, 1)), xx).T + - kron(ones((cx, 1)), yy) - 2 * xy) + d = abs(np.kron(np.ones((cy, 1)), xx).T + + np.kron(np.ones((cx, 1)), yy) - 2 * xy) return np.sqrt(d) @@ -227,15 +226,15 @@ def symmetrize(W, symmetrize_type='average'): Examples -------- >>> import numpy as np - >>> from pygsp.utils import symmetrize + >>> from pygsp import utils >>> x = np.array([[1,0],[3,4.]]) >>> x array([[ 1., 0.], [ 3., 4.]]) - >>> symmetrize(x) + >>> utils.symmetrize(x) array([[ 1. , 1.5], [ 1.5, 4. ]]) - >>> symmetrize(x, symmetrize_type='full') + >>> utils.symmetrize(x, symmetrize_type='full') array([[ 1., 3.], [ 3., 4.]]) From 1884e0356af4e40ff8cfbbee61fc2de6fa871dee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 12:14:22 +0200 Subject: [PATCH 174/392] nngraphs: normalize imports --- pygsp/graphs/nngraphs/bunny.py | 6 ++--- pygsp/graphs/nngraphs/cube.py | 2 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 1 + pygsp/graphs/nngraphs/imgpatches.py | 2 +- pygsp/graphs/nngraphs/nngraph.py | 27 ++++++++++++----------- pygsp/graphs/nngraphs/sphere.py | 2 +- pygsp/graphs/nngraphs/twomoons.py | 6 ++--- 7 files changed, 24 insertions(+), 22 deletions(-) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index a4c7ad99..d5dbf6fd 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from pygsp.graphs import NNGraph -from pygsp.utils import loadmat +from pygsp import utils +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class Bunny(NNGraph): @@ -21,7 +21,7 @@ class Bunny(NNGraph): def __init__(self, **kwargs): - data = loadmat('pointclouds/bunny') + data = utils.loadmat('pointclouds/bunny') plotting = {'vertex_size': 10, 'vertex_color': (1, 1, 1, 1), diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 132b90e3..3795fa64 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -2,7 +2,7 @@ import numpy as np -from pygsp.graphs import NNGraph +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class Cube(NNGraph): diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index dad5d99c..701066bb 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +# prevent circular import in Python < 3.5 from pygsp.graphs import Graph, Grid2d, ImgPatches diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index bf23c967..42bc946f 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from pygsp.graphs import NNGraph from pygsp import utils +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class ImgPatches(NNGraph): diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3d9b40d7..8e21dab0 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -1,17 +1,18 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix -from scipy.spatial import KDTree +from scipy import sparse, spatial + +from pygsp import utils +from pygsp.graphs import Graph # prevent circular import in Python < 3.5 -from pygsp.graphs import Graph -from pygsp.utils import symmetrize try: - import pyflann as fl + import pyflann as pfl pfl_import = True -except Exception as e: - print('ERROR : Could not import pyflann. Try to install it for faster kNN computations.') +except Exception: + print('ERROR: Could not import pyflann. ' + 'Try to install it for faster kNN computations.') pfl_import = False @@ -114,8 +115,8 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, spv = np.zeros((N * k)) if self.use_flann and pfl_import: - fl.set_distance_type(dist_type, order=order) - flann = fl.FLANN() + 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 @@ -124,7 +125,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, algorithm='kdtree') else: - kdt = KDTree(Xout) + kdt = spatial.KDTree(Xout) D, NN = kdt.query(Xout, k=(k + 1), p=dist_translation[dist_type]) @@ -136,7 +137,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, elif self.NNtype == 'radius': - kdt = KDTree(Xout) + kdt = spatial.KDTree(Xout) D, NN = kdt.query(Xout, k=None, distance_upper_bound=epsilon, p=dist_translation[dist_type]) count = 0 @@ -159,7 +160,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, else: raise ValueError('Unknown NNtype {}'.format(self.NNtype)) - W = csc_matrix((spv, (spi, spj)), shape=(N, N)) + W = sparse.csc_matrix((spv, (spi, spj)), shape=(N, N)) # Sanity check if np.shape(W)[0] != np.shape(W)[1]: @@ -167,7 +168,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, # Enforce symmetry. Note that checking symmetry with # np.abs(W - W.T).sum() is as costly as the symmetrization itself. - W = symmetrize(W, symmetrize_type=symmetrize_type) + W = utils.symmetrize(W, symmetrize_type=symmetrize_type) super(NNGraph, self).__init__(W=W, gtype=gtype, plotting=plotting, coords=Xout, **kwargs) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 0d226034..49596f22 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -2,7 +2,7 @@ import numpy as np -from pygsp.graphs import NNGraph +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class Sphere(NNGraph): diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 16424ca2..c44628f2 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -2,8 +2,8 @@ import numpy as np -from pygsp.graphs import NNGraph -from pygsp.utils import loadmat +from pygsp import utils +from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class TwoMoons(NNGraph): @@ -69,7 +69,7 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, if moontype == 'standard': gtype = 'Two Moons standard' N1, N2 = 1000, 1000 - data = loadmat('pointclouds/two_moons') + data = utils.loadmat('pointclouds/two_moons') Xin = data['features'][:dim].T elif moontype == 'synthesized': From 277848847c0dccdcc08e85a0f9e3d2f6eb20ba9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 12:23:51 +0200 Subject: [PATCH 175/392] graphs: normalize imports --- pygsp/graphs/airfoil.py | 10 +++++----- pygsp/graphs/barabasialbert.py | 7 +++---- pygsp/graphs/comet.py | 8 ++++---- pygsp/graphs/community.py | 23 +++++++++++------------ pygsp/graphs/davidsensornet.py | 10 +++++----- pygsp/graphs/erdosrenyi.py | 6 +++--- pygsp/graphs/fullconnected.py | 2 +- pygsp/graphs/graph.py | 6 +++--- pygsp/graphs/grid2d.py | 18 +++++++++--------- pygsp/graphs/logo.py | 6 +++--- pygsp/graphs/lowstretchtree.py | 6 +++--- pygsp/graphs/minnesota.py | 6 +++--- pygsp/graphs/path.py | 8 ++++---- pygsp/graphs/randomregular.py | 12 ++++++------ pygsp/graphs/randomring.py | 6 +++--- pygsp/graphs/ring.py | 8 ++++---- pygsp/graphs/sensor.py | 14 +++++++------- pygsp/graphs/stochasticblockmodel.py | 6 +++--- pygsp/graphs/swissroll.py | 8 ++++---- pygsp/graphs/torus.py | 8 ++++---- 20 files changed, 88 insertions(+), 90 deletions(-) diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 135cac71..72e79b24 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import coo_matrix +from scipy import sparse -from . import Graph -from ..utils import loadmat +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Airfoil(Graph): @@ -20,12 +20,12 @@ class Airfoil(Graph): def __init__(self, **kwargs): - data = loadmat('pointclouds/airfoil') + data = utils.loadmat('pointclouds/airfoil') coords = np.concatenate((data['x'], data['y']), axis=1) i_inds = np.reshape(data['i_inds'] - 1, 12289) j_inds = np.reshape(data['j_inds'] - 1, 12289) - A = coo_matrix((np.ones(12289), (i_inds, j_inds)), shape=(4253, 4253)) + A = sparse.coo_matrix((np.ones(12289), (i_inds, j_inds)), shape=(4253, 4253)) W = (A + A.T) / 2. plotting = {"vertex_size": 30, diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index ffd16709..75185f19 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import lil_matrix +from scipy import sparse -from . import Graph -from ..utils import build_logger +from . import Graph # prevent circular import in Python < 3.5 class BarabasiAlbert(Graph): @@ -41,7 +40,7 @@ def __init__(self, N=1000, m0=1, m=1, **kwargs): if m > m0: raise ValueError('Parameter m cannot be above parameter m0.') - W = lil_matrix((N, N)) + W = sparse.lil_matrix((N, N)) for i in range(m0, N): distr = W.sum(axis=1) diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 885378c0..c24bfc22 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class Comet(Graph): @@ -34,8 +34,8 @@ def __init__(self, Nv=32, k=12, **kwargs): np.arange(k + 1, Nv), np.arange(k, Nv - 1))) - W = csc_matrix((np.ones(np.size(i_inds)), (i_inds, j_inds)), - shape=(Nv, Nv)) + W = sparse.csc_matrix((np.ones(np.size(i_inds)), (i_inds, j_inds)), + shape=(Nv, Nv)) tmpcoords = np.zeros((Nv, 2)) inds = np.arange(k) + 1 diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 59d74abc..7d2e0de0 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- -from collections import Counter -from copy import deepcopy +import collections +import copy import numpy as np -from scipy.sparse import coo_matrix -from scipy.spatial import KDTree +from scipy import sparse, spatial -from . import Graph -from ..utils import build_logger +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Community(Graph): @@ -60,7 +59,7 @@ def __init__(self, N=256, **kwargs): k_neigh = kwargs.pop('k_neigh', None) epsilon = float(kwargs.pop('epsilon', np.sqrt(2 * np.sqrt(N)) / 2)) - self.logger = build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__, **kwargs) w_data = [[], [[], []]] try: @@ -88,7 +87,7 @@ def __init__(self, N=256, **kwargs): # create labels based on the constraint given for the community sizes. No random assignation here. info['node_com'] = np.concatenate([[val] * cnt for (val, cnt) in enumerate(comm_sizes)]) - counts = Counter(info['node_com']) + counts = collections.Counter(info['node_com']) info['comm_sizes'] = np.array([cnt[1] for cnt in sorted(counts.items())]) info['world_rad'] = size_ratio * np.sqrt(N) @@ -141,7 +140,7 @@ def __init__(self, N=256, **kwargs): elif k_neigh: comm_coords = coords[first_node:first_node + com_siz] - kdtree = KDTree(comm_coords) + kdtree = spatial.KDTree(comm_coords) __, indices = kdtree.query(comm_coords, k=k_neigh + 1) pairs_set = set() @@ -153,7 +152,7 @@ def __init__(self, N=256, **kwargs): else: comm_coords = coords[first_node:first_node + com_siz] - kdtree = KDTree(comm_coords) + kdtree = spatial.KDTree(comm_coords) pairs_set = kdtree.query_pairs(epsilon) w_data[0] += [1] * len(pairs_set) @@ -195,12 +194,12 @@ def __init__(self, N=256, **kwargs): w_data[1][1] += [elem[1] for elem in inter_edges] w_data[0] += w_data[0] - tmp_w_data = deepcopy(w_data[1][0]) + tmp_w_data = copy.deepcopy(w_data[1][0]) w_data[1][0] += w_data[1][1] w_data[1][1] += tmp_w_data w_data[1] = tuple(w_data[1]) - W = coo_matrix(tuple(w_data), shape=(N, N)) + W = sparse.coo_matrix(tuple(w_data), shape=(N, N)) for key, value in {'Nc': Nc, 'info': info}.items(): setattr(self, key, value) diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 2ddcfef8..12663332 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -2,8 +2,8 @@ import numpy as np -from . import Graph -from ..utils import loadmat, distanz +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class DavidSensorNet(Graph): @@ -28,13 +28,13 @@ class DavidSensorNet(Graph): def __init__(self, N=64): if N == 64: - data = loadmat('pointclouds/david64') + data = utils.loadmat('pointclouds/david64') assert data['N'][0, 0] == N W = data['W'] coords = data['coords'] elif N == 500: - data = loadmat('pointclouds/david500') + data = utils.loadmat('pointclouds/david500') assert data['N'][0, 0] == N W = data['W'] coords = data['coords'] @@ -45,7 +45,7 @@ def __init__(self, N=64): target_dist_cutoff = -0.125 * N / 436.075 + 0.2183 T = 0.6 s = np.sqrt(-target_dist_cutoff**2/(2*np.log(T))) - d = distanz(coords.T) + d = utils.distanz(coords.T) W = np.exp(-np.power(d, 2)/(2.*s**2)) W[W < T] = 0 W[np.diag_indices(N)] = 0 diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 09fc97d1..429a41d2 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csr_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class ErdosRenyi(Graph): @@ -54,7 +54,7 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, else: indices = tuple(map(lambda coord: coord[indices], np.tril_indices(N, -1))) - matrix = csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) + matrix = sparse.csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) self.W = matrix if directed else matrix + matrix.T self.A = self.W > 0 diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index cad4bcc5..3dfb2fd5 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -2,7 +2,7 @@ import numpy as np -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class FullConnected(Graph): diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 3ac2b9f8..864535db 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -6,7 +6,7 @@ from scipy import sparse from scipy.linalg import svd -from pygsp.utils import build_logger +from pygsp import utils class Graph(object): @@ -85,7 +85,7 @@ class Graph(object): def __init__(self, W, gtype='unknown', lap_type='combinatorial', coords=None, plotting={}, perform_checks=True, **kwargs): - self.logger = build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__, **kwargs) if len(W.shape) != 2 or W.shape[0] != W.shape[1]: raise ValueError('W has incorrect shape {}'.format(W.shape)) @@ -513,7 +513,7 @@ def extract_components(self): Examples -------- >>> from scipy import sparse - >>> from pygsp import utils, graphs + >>> from pygsp import graphs, utils >>> W = sparse.rand(10, 10, 0.2) >>> W = utils.symmetrize(W) >>> G = graphs.Graph(W=W) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index e181be14..37507d33 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import diags +from scipy import sparse -from . import Graph -from ..utils import symmetrize +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Grid2d(Graph): @@ -48,12 +48,12 @@ def __init__(self, shape=(3,), **kwargs): diag_1[(w - 1)::w] = 0 stride = w diag_2 = np.ones((h * w - stride,)) - W = diags(diagonals=[diag_1, diag_2], - offsets=[-1, -stride], - shape=(h * w, h * w), - format='csr', - dtype='float') - W = symmetrize(W, symmetrize_type='full') + W = sparse.diags(diagonals=[diag_1, diag_2], + offsets=[-1, -stride], + shape=(h * w, h * w), + format='csr', + dtype='float') + W = utils.symmetrize(W, symmetrize_type='full') x = np.kron(np.ones((h, 1)), (np.arange(w) / float(w)).reshape(w, 1)) y = np.kron(np.ones((w, 1)), np.arange(h) / float(h)).reshape(h * w, 1) diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index f598a85d..70c9d319 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -2,8 +2,8 @@ import numpy as np -from . import Graph -from ..utils import loadmat +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Logo(Graph): @@ -19,7 +19,7 @@ class Logo(Graph): def __init__(self, **kwargs): - data = loadmat('pointclouds/logogsp') + data = utils.loadmat('pointclouds/logogsp') self.info = {"idx_g": data["idx_g"], "idx_s": data["idx_s"], diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index c47e119a..ef5028e8 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class LowStretchTree(Graph): @@ -48,7 +48,7 @@ def __init__(self, k=6, **kwargs): XCoords = np.concatenate((XCoords, XCoords + 2**p)) XCoords = np.kron(np.ones((2)), XCoords) - W = csc_matrix((np.ones((np.shape(ii))), (ii, jj))) + W = sparse.csc_matrix((np.ones((np.shape(ii))), (ii, jj))) coords = np.concatenate((XCoords[:, np.newaxis], YCoords[:, np.newaxis]), axis=1) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 082b55bf..bbb1e53e 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -2,8 +2,8 @@ import numpy as np -from . import Graph -from ..utils import loadmat +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Minnesota(Graph): @@ -28,7 +28,7 @@ class Minnesota(Graph): def __init__(self, connect=True): - data = loadmat('pointclouds/minnesota') + data = utils.loadmat('pointclouds/minnesota') self.labels = data['labels'] A = data['A'] diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index fba50e4b..f38c60ae 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class Path(Graph): @@ -31,8 +31,8 @@ def __init__(self, N=16): inds_i = np.concatenate((np.arange(N - 1), np.arange(1, N))) inds_j = np.concatenate((np.arange(1, N), np.arange(N - 1))) - W = csc_matrix((np.ones((2*(N - 1))), (inds_i, inds_j)), - shape=(N, N)) + W = sparse.csc_matrix((np.ones((2*(N - 1))), (inds_i, inds_j)), + shape=(N, N)) coords = np.concatenate(((np.arange(N) + 1)[:, np.newaxis], np.zeros((N, 1))), axis=1) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 5cbfbb00..5eb8fde2 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import lil_matrix +from scipy import sparse -from . import Graph -from ..utils import build_logger +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class RandomRegular(Graph): @@ -45,7 +45,7 @@ class RandomRegular(Graph): def __init__(self, N=64, k=6, maxIter=10, **kwargs): self.k = k - self.logger = build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__, **kwargs) # continue until a proper graph is formed if (N * k) % 2 == 1: @@ -55,7 +55,7 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): U = np.kron(np.ones(k), np.arange(N)) # the graphs adjacency matrix - A = lil_matrix(np.zeros((N, N))) + A = sparse.lil_matrix(np.zeros((N, N))) edgesTested = 0 repetition = 1 @@ -82,7 +82,7 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): repetition = repetition + 1 edgesTested = 0 U = np.kron(np.ones(k), np.arange(N)) - A = lil_matrix(np.zeros((N, N))) + A = sparse.lil_matrix(np.zeros((N, N))) else: # add edge to graph A[v1, v2] = 1 diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index f2acc09e..b70070f3 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class RandomRing(Graph): @@ -32,7 +32,7 @@ def __init__(self, N=64): inds_j = np.arange(1, N) inds_i = np.arange(N - 1) - W = csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) + W = sparse.csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) W = W.tolil() W[N - 1, 0] = weightend W = W + W.T diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index af3309bf..7324925d 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class Ring(Graph): @@ -49,8 +49,8 @@ def __init__(self, N=64, k=1, **kwargs): i_inds[2*N*(k - 1) + tmpN] = tmpN i_inds[2*N*(k - 1) + tmpN] = np.remainder(tmpN + k + 1, N) - W = csc_matrix((np.ones((2*num_edges)), (i_inds, j_inds)), - shape=(N, N)) + W = sparse.csc_matrix((np.ones((2*num_edges)), (i_inds, j_inds)), + shape=(N, N)) plotting = {'limits': np.array([-1, 1, -1, 1])} diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index c3a9aff7..4e2e7201 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import lil_matrix, csc_matrix +from scipy import sparse -from . import Graph -from ..utils import build_logger, distanz +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class Sensor(Graph): @@ -41,7 +41,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, self.n_try = n_try self.distribute = distribute - self.logger = build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__, **kwargs) if connected: for x in range(self.n_try): @@ -58,7 +58,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, else: W, coords = self._create_weight_matrix(N, distribute, regular, Nc) - W = lil_matrix(W) + W = sparse.lil_matrix(W) W = (W + W.T) / 2. gtype = 'regular sensor' if self.regular else 'sensor' @@ -107,7 +107,7 @@ def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): target_dist_cutoff = 2*N**(-0.5) T = 0.6 s = np.sqrt(-target_dist_cutoff**2/(2*np.log(T))) - d = distanz(x=coords.T) + d = utils.distanz(x=coords.T) W = np.exp(-d**2/(2.*s**2)) W -= np.diag(np.diag(W)) @@ -119,5 +119,5 @@ def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): W = np.where(W < T, 0, W) W = np.where(W2 > 0, W2, W) - W = csc_matrix(W) + W = sparse.csc_matrix(W) return W, coords diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index e7beb1bf..d2f34947 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csr_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class StochasticBlockModel(Graph): @@ -92,7 +92,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, nb_row = 0 nb_col += 1 - W = csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) + W = sparse.csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) if undirected: W = W + W.T diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index ebe03961..3e6e4f93 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -2,8 +2,8 @@ import numpy as np -from . import Graph -from ..utils import distanz, rescale_center +from pygsp import utils +from . import Graph # prevent circular import in Python < 3.5 class SwissRoll(Graph): @@ -63,8 +63,8 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, self.x = x self.dim = dim - coords = rescale_center(x) - dist = distanz(coords) + coords = utils.rescale_center(x) + dist = utils.distanz(coords) W = np.exp(-np.power(dist, 2) / (2. * s**2)) W -= np.diag(np.diag(W)) W[W < thresh] = 0 diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 072f21f7..45cbf717 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- import numpy as np -from scipy.sparse import csc_matrix +from scipy import sparse -from . import Graph +from . import Graph # prevent circular import in Python < 3.5 class Torus(Graph): @@ -65,8 +65,8 @@ def __init__(self, Nv=16, Mv=None, **kwargs): j_inds[K*Mv + (Mv - 1)*2*Nv + tmp2Nv] = \ np.concatenate(((Mv - 1)*Nv + tmpNv, tmpNv)) - W = csc_matrix((np.ones((K*Mv + J*Nv)), (i_inds, j_inds)), - shape=(Mv*Nv, Mv*Nv)) + W = sparse.csc_matrix((np.ones((K*Mv + J*Nv)), (i_inds, j_inds)), + shape=(Mv*Nv, Mv*Nv)) # Create coordinate T = 1.5 + np.sin(np.arange(Mv)*2*np.pi/Mv).reshape(1, Mv) From 436868addfbaba4c71c531b1572ec2bee4347165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 12:24:51 +0200 Subject: [PATCH 176/392] filters: normalize imports --- pygsp/filters/abspline.py | 2 +- pygsp/filters/approximations.py | 7 ++++--- pygsp/filters/expwin.py | 2 +- pygsp/filters/filter.py | 23 +++++++++++++---------- pygsp/filters/gabor.py | 4 +--- pygsp/filters/halfcosine.py | 2 +- pygsp/filters/heat.py | 2 +- pygsp/filters/held.py | 2 +- pygsp/filters/itersine.py | 2 +- pygsp/filters/mexicanhat.py | 2 +- pygsp/filters/meyer.py | 2 +- pygsp/filters/papadakis.py | 2 +- pygsp/filters/regular.py | 2 +- pygsp/filters/simoncelli.py | 2 +- pygsp/filters/simpletf.py | 2 +- pygsp/filters/warpedtranslates.py | 2 +- 16 files changed, 31 insertions(+), 29 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index dcc941a7..80f66e9d 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -3,8 +3,8 @@ import numpy as np from scipy import optimize -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 class Abspline(Filter): diff --git a/pygsp/filters/approximations.py b/pygsp/filters/approximations.py index 2d41111c..1b234ab2 100644 --- a/pygsp/filters/approximations.py +++ b/pygsp/filters/approximations.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import numpy as np -import scipy as sp +from scipy import sparse from pygsp import utils + _logger = utils.build_logger(__name__) @@ -101,7 +102,7 @@ def cheby_op(G, c, signal, **kwargs): for i in range(Nscales): r[tmpN + G.N*i] = 0.5 * c[i, 0] * twf_old + c[i, 1] * twf_cur - factor = 2/a1 * (G.L - a2 * sp.sparse.eye(G.N)) + factor = 2/a1 * (G.L - a2 * sparse.eye(G.N)) for k in range(2, M): twf_new = factor.dot(twf_cur) - twf_old for i in range(Nscales): @@ -147,7 +148,7 @@ def cheby_rect(G, bounds, signal, **kwargs): r = np.zeros((G.N)) b1, b2 = np.arccos(2. * bounds / G.lmax - 1.) - factor = 4./G.lmax * G.L - 2.*sp.sparse.eye(G.N) + factor = 4./G.lmax * G.L - 2.*sparse.eye(G.N) T_old = signal T_cur = factor.dot(signal) / 2. diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 87b40795..12b84923 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Expwin(Filter): diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 35e8012b..97577ceb 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- -from copy import deepcopy +import copy import numpy as np from pygsp import utils -from ..operators.transforms import gft, igft +# prevent circular import in Python < 3.5 from . import approximations +from ..operators.transforms import gft, igft _logger = utils.build_logger(__name__) @@ -126,12 +127,14 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, fs = np.tile(fie, (Ns, 1)).T * gft(self.G, s) return igft(self.G, fs) else: - return igft(self.G, fie * gft(self.G, s)) + fs = fie * gft(self.G, s) + return igft(self.G, fs) else: tmpN = np.arange(N, dtype=int) for i in range(Nf): if is2d: - fs = np.tile(fie[i], (Ns, 1)).T * gft(self.G, s) + fs = gft(self.G, s) + fs *= np.tile(fie[i], (Ns, 1)).T c[tmpN + N * i] = igft(self.G, fs) else: fs = fie[i] * gft(self.G, s) @@ -247,8 +250,8 @@ def synthesis(self, c, order=30, method=None, **kwargs): s += igft(np.conjugate(self.G.U), fc) elif method == 'cheby': - cheb_coeffs = approximations.compute_cheby_coeff( - self, m=order, N=order + 1) + cheb_coeffs = approximations.compute_cheby_coeff(self, m=order, + N=order+1) s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) @@ -263,8 +266,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): for i in range(Nf): s += approximations.lanczos_op(self.G, self.g[i], - c[i * N + tmpN], - order=order) + c[i * N + tmpN], order=order) else: raise ValueError('Unknown method: {}'.format(method)) @@ -375,11 +377,12 @@ def can_dual_func(g, n, x): ret = s[:, n] return ret - gdual = deepcopy(self) + gdual = copy.deepcopy(self) Nf = len(self.g) for i in range(Nf): - gdual.g[i] = lambda x, ind=i: can_dual_func(self, ind, deepcopy(x)) + gdual.g[i] = lambda x, ind=i: can_dual_func(self, ind, + copy.deepcopy(x)) return gdual diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 28e49bd3..18e789b4 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -1,9 +1,7 @@ # -*- coding: utf-8 -*- -import numpy as np - -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 _logger = utils.build_logger(__name__) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index 22cb0c14..69cfde0b 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class HalfCosine(Filter): diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 11555361..41f26222 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -3,8 +3,8 @@ import numpy as np from numpy import linalg -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 _logger = utils.build_logger(__name__) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index cd28bc41..587bc968 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Held(Filter): diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 589ff214..0ba59083 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Itersine(Filter): diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 7c5b7497..bf5e35d0 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -2,8 +2,8 @@ import numpy as np -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 class MexicanHat(Filter): diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 6b598d9e..ca675ae2 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -2,8 +2,8 @@ import numpy as np -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 _logger = utils.build_logger(__name__) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index f3ccc30e..a8337f81 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Papadakis(Filter): diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index f14784e4..9006037d 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Regular(Filter): diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 311e8180..f28252ad 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -2,7 +2,7 @@ import numpy as np -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class Simoncelli(Filter): diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletf.py index 843b0861..7f594c19 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletf.py @@ -2,8 +2,8 @@ import numpy as np -from . import Filter from pygsp import utils +from . import Filter # prevent circular import in Python < 3.5 _logger = utils.build_logger(__name__) diff --git a/pygsp/filters/warpedtranslates.py b/pygsp/filters/warpedtranslates.py index a73e0628..874aeb67 100644 --- a/pygsp/filters/warpedtranslates.py +++ b/pygsp/filters/warpedtranslates.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from . import Filter +from . import Filter # prevent circular import in Python < 3.5 class WarpedTranslates(Filter): From ddf34bd2adf7897645c8249e54013f583e601f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 12:28:45 +0200 Subject: [PATCH 177/392] features: normalize imports --- pygsp/features.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 684d569e..3bcde6ca 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -7,9 +7,7 @@ import numpy as np -from .graphs import Graph -from .filters import Filter -from .utils import filterbank_handler +from pygsp import graphs, filters, utils def compute_avg_adj_deg(G): @@ -30,7 +28,7 @@ def compute_avg_adj_deg(G): return np.sum(np.dot(G.A, G.A), axis=1) / (np.sum(G.A, axis=1) + 1.) -@filterbank_handler +@utils.filterbank_handler def compute_tig(filt, method=None, **kwargs): r""" Compute the Tig for a given filter or filterbank. @@ -54,7 +52,7 @@ def compute_tig(filt, method=None, **kwargs): return filt.analysis(signals, method=method, **kwargs) -@filterbank_handler +@utils.filterbank_handler def compute_norm_tig(filt, method=None, *args, **kwargs): r""" Compute the :math:`\ell_2` norm of the Tig. @@ -95,9 +93,8 @@ def atom(x): spectr = np.zeros((G.N, M)) for shift_idx in range(M): - shft_filter = Filter(G, - filters=[lambda x: atom(x - scale[shift_idx])], - **kwargs) + shft_filter = filters.Filter(G, [lambda x: atom(x - scale[shift_idx])], + **kwargs) spectr[:, shift_idx] = compute_norm_tig(shft_filter, method=method)**2 G.spectr = spectr From e0d2232b0f2aff359cd6fce1933763de4ea271e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 12:29:56 +0200 Subject: [PATCH 178/392] easier to ask for forgiveness than permission --- pygsp/features.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 3bcde6ca..718ab00a 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -7,7 +7,7 @@ import numpy as np -from pygsp import graphs, filters, utils +from pygsp import filters, utils def compute_avg_adj_deg(G): @@ -22,9 +22,6 @@ def compute_avg_adj_deg(G): G: Graph Graph on which the statistic is extracted """ - if not isinstance(G, Graph): - raise ValueError("Graph object expected as first argument.") - return np.sum(np.dot(G.A, G.A), axis=1) / (np.sum(G.A, axis=1) + 1.) @@ -45,9 +42,6 @@ def compute_tig(filt, method=None, **kwargs): i: int (optional) Index of the filter to analyse (default: 0) """ - if not isinstance(filt, Filter): - raise ValueError("Filter object expected as first argument.") - signals = np.eye(filt.G.N) return filt.analysis(signals, method=method, **kwargs) From 164605ac90a1dc5fc938225428dbb70effe58f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 14:30:30 +0200 Subject: [PATCH 179/392] operators: normalize imports --- pygsp/operators/localization.py | 10 +++---- pygsp/operators/reduction.py | 53 ++++++++++++++++----------------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py index 8c66ffc9..91190d8c 100644 --- a/pygsp/operators/localization.py +++ b/pygsp/operators/localization.py @@ -2,11 +2,11 @@ import numpy as np -from ..utils import build_logger -from .transforms import gft, igft +from pygsp import utils +from . import transforms # prevent circular import in Python < 3.5 -logger = build_logger(__name__) +logger = utils.build_logger(__name__) def localize(g, i): @@ -78,9 +78,9 @@ def translate(G, f, i): """ - fhat = gft(G, f) + fhat = transforms.gft(G, f) nt = np.shape(f)[1] - ft = np.sqrt(G.N) * igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) + ft = np.sqrt(G.N) * transforms.igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) return ft diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index 2cc12ebe..b7bec4a7 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -2,14 +2,12 @@ import numpy as np from scipy import sparse, stats -from scipy.sparse.linalg import eigs, spsolve +from scipy.sparse import linalg -from ..utils import build_logger, resistance_distance -from ..graphs import Graph -from ..filters import Filter +from pygsp import graphs, filters, utils -logger = build_logger(__name__) +logger = utils.build_logger(__name__) def graph_sparsify(M, epsilon, maxiter=10): @@ -46,7 +44,7 @@ def graph_sparsify(M, epsilon, maxiter=10): """ # Test the input parameters - if isinstance(M, Graph): + if isinstance(M, graphs.Graph): if not M.lap_type == 'combinatorial': raise NotImplementedError L = M.L @@ -59,9 +57,9 @@ def graph_sparsify(M, epsilon, maxiter=10): raise ValueError('GRAPH_SPARSIFY: Epsilon out of required range') # Not sparse - resistance_distances = resistance_distance(L).toarray() + resistance_distances = utils.resistance_distance(L).toarray() # Get the Weight matrix - if isinstance(M, Graph): + if isinstance(M, graphs.Graph): W = M.W else: W = np.diag(L.diagonal()) - L.toarray() @@ -102,19 +100,19 @@ def graph_sparsify(M, epsilon, maxiter=10): sparserW = sparserW + sparserW.T sparserL = sparse.diags(sparserW.diagonal(), 0) - sparserW - if Graph(W=sparserW).is_connected(): + if graphs.Graph(W=sparserW).is_connected(): break elif i == maxiter - 1: logger.warning('Despite attempts to reduce epsilon, sparsified graph is disconnected') else: epsilon -= (epsilon - 1/np.sqrt(N)) / 2. - if isinstance(M, Graph): + if isinstance(M, graphs.Graph): sparserW = sparse.diags(sparserL.diagonal(), 0) - sparserL if not M.is_directed(): sparserW = (sparserW + sparserW.T) / 2. - Mnew = Graph(W=sparserW) + Mnew = graphs.Graph(W=sparserW) M.copy_graph_attributes(Mnew) else: Mnew = sparse.lil_matrix(sparserL) @@ -153,7 +151,7 @@ def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): L_reg = G.L + reg_eps * sparse.eye(G.N) K_reg = getattr(G.mr, 'K_reg', kron_reduction(L_reg, keep_inds)) green_kernel = getattr(G.mr, 'green_kernel', - Filter(G, filters=lambda x: 1. / (reg_eps + x))) + filters.Filter(G, filters=lambda x: 1. / (reg_eps + x))) alpha = K_reg.dot(f_subsampled) @@ -243,7 +241,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, if hasattr(Gs[i], 'U'): V = Gs[i].U[:, -1] else: - V = eigs(Gs[i].L, 1)[1][:, 0] + V = linalg.eigs(Gs[i].L, 1)[1][:, 0] V *= np.sign(V[0]) ind = np.nonzero(V >= 0)[0] @@ -270,7 +268,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, L_reg = Gs[i].L + reg_eps * sparse.eye(Gs[i].N) Gs[i].mr['K_reg'] = kron_reduction(L_reg, ind) - Gs[i].mr['green_kernel'] = Filter(Gs[i], filters=lambda x: 1./(reg_eps + x)) + Gs[i].mr['green_kernel'] = filters.Filter(Gs[i], filters=lambda x: 1./(reg_eps + x)) return Gs @@ -303,7 +301,7 @@ def kron_reduction(G, ind): See :cite:`dorfler2013kron` """ - if isinstance(G, Graph): + if isinstance(G, graphs.Graph): if G.lap_type != 'combinatorial': msg = 'Unknown reduction for {} Laplacian.'.format(G.lap_type) @@ -327,13 +325,13 @@ def kron_reduction(G, ind): L_out_in = L[np.ix_(ind_comp, ind)].tocsc() L_comp = L[np.ix_(ind_comp, ind_comp)].tocsc() - Lnew = L_red - L_in_out.dot(spsolve(L_comp, L_out_in)) + Lnew = L_red - L_in_out.dot(linalg.spsolve(L_comp, L_out_in)) # Make the laplacian symmetric if it is almost symmetric! if np.abs(Lnew - Lnew.T).sum() < np.spacing(1) * np.abs(Lnew).sum(): Lnew = (Lnew + Lnew.T) / 2. - if isinstance(G, Graph): + if isinstance(G, graphs.Graph): # Suppress the diagonal ? This is a good question? Wnew = sparse.diags(Lnew.diagonal(), 0) - Lnew Snew = Lnew.diagonal() - np.ravel(Wnew.sum(0)) @@ -344,8 +342,8 @@ def kron_reduction(G, ind): Wnew = Wnew - Wnew.diagonal() coords = G.coords[ind, :] if len(G.coords.shape) else np.ndarray(None) - Gnew = Graph(W=Wnew, coords=coords, lap_type=G.lap_type, - plotting=G.plotting, gtype='Kron reduction') + Gnew = graphs.Graph(W=Wnew, coords=coords, lap_type=G.lap_type, + plotting=G.plotting, gtype='Kron reduction') else: Gnew = Lnew @@ -408,7 +406,7 @@ def pyramid_analysis(Gs, f, **kwargs): for i in range(levels): # Low pass the signal - s_low = Filter(Gs[i], filters=h_filters[i]).analysis(ca[i], **kwargs) + s_low = filters.Filter(Gs[i], filters=h_filters[i]).analysis(ca[i], **kwargs) # Keep only the coefficient on the selected nodes ca.append(s_low[Gs[i+1].mr['idx']]) # Compute prediction @@ -582,9 +580,9 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): if use_landweber: x = np.zeros(N) z = np.concatenate((ca, pe), axis=0) - green_kernel = Filter(G, filters=lambda x: 1./(x+reg_eps)) + green_kernel = filters.Filter(G, filters=lambda x: 1./(x+reg_eps)) PhiVlt = green_kernel.analysis(S.T, **kwargs).T - filt = Filter(G, filters=h_filter, **kwargs) + filt = filters.Filter(G, filters=h_filter, **kwargs) for iteration in range(landweber_its): h_filtered_sig = filt.analysis(x, **kwargs) @@ -602,7 +600,7 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): L_out_in = reg_L[np.ix_(elim_inds, keep_inds)] L_comp = reg_L[np.ix_(elim_inds, elim_inds)] - next_term = L_red * alpha_new - L_in_out * spsolve(L_comp, L_out_in * alpha_new) + next_term = L_red * alpha_new - L_in_out * linalg.spsolve(L_comp, L_out_in * alpha_new) next_up = sparse.csr_matrix((next_term, (keep_inds, [1] * nb_ind)), shape=(N, 1)) x += landweber_tau * filt.analysis(x_up - next_up, **kwargs) + z_delt[nb_ind:] @@ -613,13 +611,13 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): # and compute the full analysis operator T_a H = G.U * sparse.diags(h_filter(G.e), 0) * G.U.T Phi = G.U * sparse.diags(1./(reg_eps + G.e), 0) * G.U.T - Ta = np.concatenate((S * H, sparse.eye(G.N) - Phi[:, keep_inds] * spsolve(Phi[np.ix_(keep_inds, keep_inds)], S*H)), axis=0) - finer_approx = spsolve(Ta.T * Ta, Ta.T * np.concatenate((ca, pe), axis=0)) + Ta = np.concatenate((S * H, sparse.eye(G.N) - Phi[:, keep_inds] * linalg.spsolve(Phi[np.ix_(keep_inds, keep_inds)], S*H)), axis=0) + finer_approx = linalg.spsolve(Ta.T * Ta, Ta.T * np.concatenate((ca, pe), axis=0)) def _tree_depths(A, root): r"""Empty docstring. TODO.""" - if not Graph(A=A).is_connected(): + if not graphs.Graph(A=A).is_connected(): raise ValueError('Graph is not connected') N = np.shape(A)[0] @@ -672,7 +670,6 @@ def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', Indices of the vertices of the previous tree that are kept for the subsequent tree. """ - from pygsp.graphs import Graph if not root: if hasattr(G, 'root'): @@ -747,7 +744,7 @@ def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', depths = depths/2. # Store new tree - Gtemp = Graph(new_W, coords=Gs[lev].coords[keep_inds], limits=G.limits, gtype='tree', root=new_root) + Gtemp = graphs.Graph(new_W, coords=Gs[lev].coords[keep_inds], limits=G.limits, gtype='tree', root=new_root) Gs[lev].copy_graph_attributes(Gtemp, False) if compute_full_eigen: From c12dd3a6f6b9b4fa74595ffaaa5eed1b8038cbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 14:32:18 +0200 Subject: [PATCH 180/392] normalize imports --- pygsp/optimization.py | 3 ++- pygsp/plotting.py | 6 +++--- pygsp/tests/test_utils.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index 3d3611f6..ae069f35 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -5,7 +5,8 @@ graphs. """ -from pygsp import utils, operators +from pygsp import operators, utils + logger = utils.build_logger(__name__) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 241e0091..27d9eda0 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -410,7 +410,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, Examples -------- - >>> from pygsp import filters, plotting, graphs + >>> from pygsp import graphs, filters, plotting >>> G = graphs.Logo() >>> mh = filters.MexicanHat(G) >>> plotting.plot_filter(mh) @@ -728,13 +728,13 @@ def plot_spectrogram(G, node_idx=None): >>> plotting.plot_spectrogram(G) """ - from pygsp.features import compute_spectrogram + from pygsp import features if not qtg_import: raise NotImplementedError("You need pyqtgraph to plot the spectrogram at the moment. Please install dependency and retry.") if not hasattr(G, 'spectr'): - compute_spectrogram(G) + features.compute_spectrogram(G) M = G.spectr.shape[1] spectr = np.ravel(G.spectr[node_idx, :] if node_idx is not None else G.spectr) diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index f20476e4..f44d066d 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -10,7 +10,7 @@ import numpy as np from scipy import sparse -from pygsp import utils, graphs +from pygsp import graphs, utils class TestCase(unittest.TestCase): From bd41b718b1963f927ac63fc5180054ecee54314f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 15:00:26 +0200 Subject: [PATCH 181/392] vec2mat: fix integer division --- pygsp/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index df86924a..b3ed7cb0 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -384,11 +384,11 @@ def vec2mat(d, Nf): """ if len(np.shape(d)) == 1: M = np.shape(d)[0] - return np.reshape(d, (M / Nf, Nf), order='F') + return np.reshape(d, (M // Nf, Nf), order='F') if len(np.shape(d)) == 2: M, N = np.shape(d) - return np.reshape(d, (M / Nf, Nf, N), order='F') + return np.reshape(d, (M // Nf, Nf, N), order='F') def extract_patches(img, patch_shape=(3, 3)): From c255c88f114ac0bb771292e8812ef31ee6351f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 15:02:27 +0200 Subject: [PATCH 182/392] windowed graph Fourier transforms * only the Gabor implementation is working * the translate operator is not implemented correctly eider --- pygsp/operators/__init__.py | 14 ++-- pygsp/operators/localization.py | 2 + pygsp/operators/transforms.py | 114 +++++++++++++++++++------------- pygsp/tests/test_operators.py | 16 +++++ 4 files changed, 93 insertions(+), 53 deletions(-) diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index 737a5cd6..dd01980b 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -10,11 +10,11 @@ **Transforms** (frequency and vertex-frequency) -* :func:`pygsp.operators.gft`: graph Fourier transform +* :func:`pygsp.operators.gft`: graph Fourier transform (GFT) * :func:`pygsp.operators.igft`: inverse graph Fourier transform -* :func:`pygsp.operators.generalized_wft`: graph windowed Fourier transform -* :func:`pygsp.operators.gabor_wft`: graph windowed Fourier transform -* :func:`pygsp.operators.ngwft`: normalized graph windowed Fourier transform +* :func:`pygsp.operators.gft_windowed`: windowed GFT +* :func:`pygsp.operators.gft_windowed_gabor`: Gabor windowed GFT +* :func:`pygsp.operators.gft_windowed_normalized`: normalized windowed GFT **Localization** @@ -44,9 +44,9 @@ _TRANSFORMS = [ 'gft', 'igft', - 'generalized_wft', - 'gabor_wft', - 'ngwft', + 'gft_windowed', + 'gft_windowed_gabor', + 'gft_windowed_normalized', ] _LOCALIZATION = [ 'localize', diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py index 91190d8c..9b4ed37f 100644 --- a/pygsp/operators/localization.py +++ b/pygsp/operators/localization.py @@ -78,6 +78,8 @@ def translate(G, f, i): """ + raise NotImplementedError('Current implementation is not working.') + fhat = transforms.gft(G, f) nt = np.shape(f)[1] diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py index 6b3ad081..f67df034 100644 --- a/pygsp/operators/transforms.py +++ b/pygsp/operators/transforms.py @@ -95,26 +95,29 @@ def igft(G, s_hat): return np.dot(U, s_hat) -def generalized_wft(G, g, f, lowmemory=True): +def gft_windowed(G, g, f, lowmemory=True): r""" - Graph windowed Fourier transform + Windowed graph Fourier transform. Parameters ---------- - G : Graph - g : ndarray or Filter - Window (graph signal or kernel) - f : ndarray - Graph signal - lowmemory : bool - use less memory (default=True) + G : Graph + g : ndarray or Filter + Window (graph signal or kernel). + f : ndarray + Graph signal in the vertex domain. + lowmemory : bool + Use less memory (default=True). Returns ------- - C : ndarray - Coefficients + C : ndarray + Coefficients. """ + + raise NotImplementedError('Current implementation is not working.') + Nf = np.shape(f)[1] if isinstance(g, list): @@ -131,9 +134,10 @@ def generalized_wft(G, g, f, lowmemory=True): else: # Compute the translate of g + # TODO: use operators.translate() ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(G.N) * np.dot(G.U, (np.kron(np.ones((G.N)), ghat)*G.U.T)) - C = np.zeros((G.N, G.N)) + C = np.empty((G.N, G.N)) for j in range(Nf): for i in range(G.N): @@ -142,26 +146,38 @@ def generalized_wft(G, g, f, lowmemory=True): return C -def gabor_wft(G, f, k): +def gft_windowed_gabor(G, f, k): r""" - Graph windowed Fourier transform + Gabor windowed graph Fourier transform. Parameters ---------- - G : Graph - f : ndarray - Graph signal - k : anonymous function - Gabor kernel + G : Graph + f : ndarray + Graph signal in the vertex domain. + k : function + Gabor kernel. See :class:`pygsp.filters.Gabor`. Returns ------- - C : Coefficient. + C : ndarray + Coefficients. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s = np.random.normal(size=G.N) + >>> C = operators.gft_windowed_gabor(G, s, lambda x: x/(1.-x)) + >>> C.shape == (G.N, G.N) + True """ - from pygsp.filters import Gabor - g = Gabor(G, k) + from pygsp import filters + + g = filters.Gabor(G, k) C = g.analysis(f) C = utils.vec2mat(C, G.N).T @@ -171,17 +187,17 @@ def gabor_wft(G, f, k): def _gwft_frame_matrix(G, g): r""" - Create the matrix of the GWFT frame + Create the GWFT frame. Parameters ---------- - G : Graph - g : window + G : Graph + g : window Returns ------- - F : ndarray - Frame + F : ndarray + Frame """ if G.N > 256: @@ -194,27 +210,29 @@ def _gwft_frame_matrix(G, g): return F -def ngwft(G, f, g, lowmemory=True): +def gft_windowed_normalized(G, g, f, lowmemory=True): r""" - Normalized graph windowed Fourier transform + Normalized windowed graph Fourier transform. Parameters ---------- - G : Graph - f : ndarray - Graph signal - g : ndarray - Window - lowmemory : bool - Use less memory. (default = True) + G : Graph + g : ndarray + Window. + f : ndarray + Graph signal in the vertex domain. + lowmemory : bool + Use less memory. (default = True) Returns ------- - C : ndarray - Coefficients + C : ndarray + Coefficients. """ + raise NotImplementedError('Current implementation is not working.') + if lowmemory: # Compute the Frame into a big matrix Frame = _ngwft_frame_matrix(G, g) @@ -223,9 +241,10 @@ def ngwft(G, f, g, lowmemory=True): else: # Compute the translate of g + # TODO: use operators.translate() ghat = np.dot(G.U.T, g) Ftrans = np.sqrt(G.N)*np.dot(G.U, (np.kron(np.ones((1, G.N)), ghat)*G.U.T)) - C = np.zeros((G.N, G.N)) + C = np.empty((G.N, G.N)) for i in range(G.N): atoms = np.kron(np.ones((G.N)), 1./G.U[:, 0])*G.U*np.kron(np.ones((G.N)), Ftrans[:, i]).T @@ -240,18 +259,21 @@ def ngwft(G, f, g, lowmemory=True): def _ngwft_frame_matrix(G, g): r""" - Create the matrix of the GWFT frame + Create the NGWFT frame. Parameters ---------- - G : Graph - g : ndarray - Window + G : Graph + g : ndarray + Window + + Returns + ------- + F : ndarray + Frame - Output parameters: - F : ndarray - Frame """ + if G.N > 256: logger.warning('It will create a big matrix, you can use other methods.') diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py index e2f6afb7..e6f18cdd 100644 --- a/pygsp/tests/test_operators.py +++ b/pygsp/tests/test_operators.py @@ -38,5 +38,21 @@ def test_fourier_transform(self): f_star = operators.igft(self.G, f_hat) np.testing.assert_allclose(self.signal, f_star) + def test_translate(self): + self.assertRaises(NotImplementedError, operators.translate, + self.G, self.signal, 42) + + def test_gft_windowed(self): + self.assertRaises(NotImplementedError, operators.gft_windowed, + self.G, None, self.signal) + + def test_gft_windowed_gabor(self): + operators.gft_windowed_gabor(self.G, self.signal, lambda x: x/(1.-x)) + + def test_gft_windowed_normalized(self): + self.assertRaises(NotImplementedError, + operators.gft_windowed_normalized, + self.G, None, self.signal) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 35410432a694d337cdb254803c9ebba62b525523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 15:06:37 +0200 Subject: [PATCH 183/392] fix set_coordinates --- 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 864535db..cce360a2 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -308,7 +308,7 @@ def set_coordinates(self, kind='spring', **kwargs): elif kind == 'ring2D': angle = np.arange(self.N) * 2 * np.pi / self.N - self.coords = np.stack([np.cos(angle), np.sin(angle)]) + self.coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) elif kind == 'random2D': self.coords = np.random.uniform(size=(self.N, 2)) From 654ca1bd54ebee8d7672dade0d6a1fdb51d3e0b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 15:08:13 +0200 Subject: [PATCH 184/392] get_edge_list: graph might have self-loops --- 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 cce360a2..a4c72618 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -885,7 +885,7 @@ def get_edge_list(self): # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) assert v_in.size == v_out.size == weights.size - assert self.Ne == 2 * v_in.size + assert self.Ne >= v_in.size # graph might have self-loops return v_in, v_out, weights From 454b08638e87743157dd3d82dcc189b775ea27a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 21 Aug 2017 15:14:09 +0200 Subject: [PATCH 185/392] graphs: update error messages --- pygsp/graphs/graph.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index a4c72618..e686c0d8 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -303,7 +303,8 @@ def set_coordinates(self, kind='spring', **kwargs): coords = np.asarray(kind) check_dim = (2 <= coords.shape[1] <= 3) if coords.ndim != 2 or coords.shape[0] != self.N or not check_dim: - raise ValueError('Expecting coords to be of size Nx2 or Nx3.') + raise ValueError('Expecting coordinates to be of size Nx2 or ' + 'Nx3.') self.coords = coords elif kind == 'ring2D': @@ -528,7 +529,7 @@ def extract_components(self): return None if self.is_directed(): - raise NotImplementedError('Focusing on undirected graphs first.') + raise NotImplementedError('Directed graphs not supported yet.') graphs = [] @@ -721,7 +722,8 @@ def compute_laplacian(self, lap_type='combinatorial'): self.L = 0.5 * (D1 + D2 - self.W - self.W.T).tocsc() elif lap_type == 'normalized': - raise NotImplementedError('Yet. Ask Nathanael.') + raise NotImplementedError('Directed graphs with normalized ' + 'Laplacian not supported yet.') else: @@ -742,8 +744,8 @@ def lmax(self): """ if not hasattr(self, '_lmax'): self.logger.warning('The largest eigenvalue G.lmax is not ' - 'available, we need to estimate it. Explicitly ' - 'call G.estimate_lmax() or ' + 'available, we need to estimate it. ' + 'Explicitly call G.estimate_lmax() or ' 'G.compute_fourier_basis() ' 'once beforehand to suppress the warning.') self.estimate_lmax() @@ -875,7 +877,7 @@ def get_edge_list(self): """ if self.is_directed(): - raise NotImplementedError + raise NotImplementedError('Directed graphs not supported yet.') else: v_in, v_out = sparse.tril(self.W).nonzero() From f0eecbaa38a730170f07a160d485db3d0ec8e339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 09:28:50 +0200 Subject: [PATCH 186/392] filter analysis and synthesis: use Chebyshev by default --- README.rst | 8 ++--- doc/tutorials/pyramid.rst | 8 ++--- pygsp/features.py | 42 +++++++++++++------------- pygsp/filters/filter.py | 59 +++++++++++++++---------------------- pygsp/tests/test_filters.py | 6 ++-- 5 files changed, 55 insertions(+), 68 deletions(-) diff --git a/README.rst b/README.rst index d58b63b6..57754d2f 100644 --- a/README.rst +++ b/README.rst @@ -36,10 +36,10 @@ documentation is available on `Read the Docs This example demonstrates how to create a graph, a filter and analyse a signal on the graph. ->>> import pygsp ->>> G = pygsp.graphs.Logo() ->>> f = pygsp.filters.Heat(G) ->>> Sl = f.analysis(G.L.todense(), method='cheby') +>>> from pygsp import graphs, filters +>>> G = graphs.Logo() +>>> f = filters.Heat(G) +>>> Sl = f.analysis(G.L.todense(), method='chebyshev') Features -------- diff --git a/doc/tutorials/pyramid.rst b/doc/tutorials/pyramid.rst index 5f8254a6..5a9fe8a0 100644 --- a/doc/tutorials/pyramid.rst +++ b/doc/tutorials/pyramid.rst @@ -38,13 +38,13 @@ Let's now create two signals and a filter, resp f, f2 and g: We will run the analysis of the two signals on the pyramid and obtain a coarse approximation for each layer, with decreasing number of nodes. Additionally, we will also get prediction errors at each node at every layer. ->>> ca, pe = operators.pyramid_analysis(Gs, f, h_filters=g) ->>> ca2, pe2 = operators.pyramid_analysis(Gs, f2, h_filters=g) +>>> ca, pe = operators.pyramid_analysis(Gs, f, h_filters=g, method='exact') +>>> ca2, pe2 = operators.pyramid_analysis(Gs, f2, h_filters=g, method='exact') Given the pyramid, the coarsest approximation and the prediction errors, we will now reconstruct the original signal on the full graph. ->>> f_pred, _ = operators.pyramid_synthesis(Gs, ca[levels], pe) ->>> f_pred2, _ = operators.pyramid_synthesis(Gs, ca2[levels], pe2) +>>> f_pred, _ = operators.pyramid_synthesis(Gs, ca[levels], pe, method='exact') +>>> f_pred2, _ = operators.pyramid_synthesis(Gs, ca2[levels], pe2, method='exact') Here are the final errors for each signal after reconstruction. diff --git a/pygsp/features.py b/pygsp/features.py index 718ab00a..2cc4885a 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -26,7 +26,7 @@ def compute_avg_adj_deg(G): @utils.filterbank_handler -def compute_tig(filt, method=None, **kwargs): +def compute_tig(filt, **kwargs): r""" Compute the Tig for a given filter or filterbank. @@ -35,19 +35,17 @@ def compute_tig(filt, method=None, **kwargs): Parameters ---------- filt: Filter object - The filter (or filterbank) to localize - method: string (optional) - Which method to use. Accept 'cheby', 'exact'. - Default : 'exact' if filt.G has U and e defined, otherwise 'cheby' - i: int (optional) - Index of the filter to analyse (default: 0) + The filter or filterbank. + kwargs: dict + Additional parameters to be passed to the + :func:`pygsp.filters.Filter.analysis` method. """ signals = np.eye(filt.G.N) - return filt.analysis(signals, method=method, **kwargs) + return filt.analysis(signals, **kwargs) @utils.filterbank_handler -def compute_norm_tig(filt, method=None, *args, **kwargs): +def compute_norm_tig(filt, **kwargs): r""" Compute the :math:`\ell_2` norm of the Tig. See :func:`compute_tig`. @@ -55,16 +53,16 @@ def compute_norm_tig(filt, method=None, *args, **kwargs): Parameters ---------- filt: Filter - The filter (or filterbank) - method: string (optional) - Which method to use. Accept 'cheby', 'exact' - (default : 'exact' if filt.G has U and e defined, otherwise 'cheby') + The filter or filterbank. + kwargs: dict + Additional parameters to be passed to the + :func:`pygsp.filters.Filter.analysis` method. """ - tig = compute_tig(filt, method=method, *args, **kwargs) + tig = compute_tig(filt, **kwargs) return np.linalg.norm(tig, axis=1, ord=2) -def compute_spectrogram(G, atom=None, M=100, method=None, **kwargs): +def compute_spectrogram(G, atom=None, M=100, **kwargs): r""" Compute the norm of the Tig for all nodes with a kernel shifted along the spectral axis. @@ -77,19 +75,21 @@ def compute_spectrogram(G, atom=None, M=100, method=None, **kwargs): Kernel to use in the spectrogram (default = exp(-M*(x/lmax)²)). M : int (optional) Number of samples on the spectral scale. (default = 100) - + kwargs: dict + Additional parameters to be passed to the + :func:`pygsp.filters.Filter.analysis` method. """ - if not atom or not hasattr(atom, '__call__'): + + if not atom: def atom(x): return np.exp(-M * (x / G.lmax)**2) scale = np.linspace(0, G.lmax, M) - spectr = np.zeros((G.N, M)) + spectr = np.empty((G.N, M)) for shift_idx in range(M): - shft_filter = filters.Filter(G, [lambda x: atom(x - scale[shift_idx])], - **kwargs) - spectr[:, shift_idx] = compute_norm_tig(shft_filter, method=method)**2 + shift_filter = filters.Filter(G, lambda x: atom(x - scale[shift_idx])) + spectr[:, shift_idx] = compute_norm_tig(shift_filter, **kwargs)**2 G.spectr = spectr return spectr diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 97577ceb..c696c08d 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -63,20 +63,20 @@ def __init__(self, G, filters, **kwargs): else: self.g = [filters] - def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, - **kwargs): + def analysis(self, s, method='chebyshev', order=30, **kwargs): r""" Operator to analyse a filterbank Parameters ---------- s : ndarray - graph signals to analyse - method : string - whether using an exact method or cheby approx (lanczos not working - now) - cheb_order : int - Order for chebyshev + Graph signals to analyse. + method : 'exact', 'chebyshev', 'lanczos' + Whether to use the exact method (via the graph Fourier transform) + or the Chebyshev polynomial approximation. The Lanczos + approximation is not working yet. + order : int + Degree of the Chebyshev polynomials. Returns ------- @@ -97,19 +97,15 @@ def analysis(self, s, method=None, cheb_order=30, lanczos_order=30, See :cite:`hammond2011wavelets` """ - if not method: - method = 'exact' if hasattr(self.G, 'U') else 'cheby' - _logger.info('The analysis method is {}'.format(method)) - - if method == 'cheby': # Chebyshev approx - cheb_coef = approximations.compute_cheby_coeff(self, m=cheb_order) + if method == 'chebyshev': + cheb_coef = approximations.compute_cheby_coeff(self, m=order) c = approximations.cheby_op(self.G, cheb_coef, s) - elif method == 'lanczos': # Lanczos approx + elif method == 'lanczos': raise NotImplementedError - # c = approximations.lanczos_op(self, s, order=lanczos_order) + # c = approximations.lanczos_op(self, s, order=order) - elif method == 'exact': # Exact computation + elif method == 'exact': Nf = len(self.g) # nb of filters N = self.G.N # nb of nodes try: @@ -183,23 +179,20 @@ def inverse(self, c, **kwargs): """ raise NotImplementedError - def synthesis(self, c, order=30, method=None, **kwargs): + def synthesis(self, c, method='chebyshev', order=30, **kwargs): r""" Synthesis operator of a filterbank Parameters ---------- - G : Graph structure. - c : Transform coefficients - method : Select the method to be used for the computation. - - 'exact' : Exact method using the graph Fourier matrix - - 'cheby' : Chebyshev polynomial approximation - - 'lanczos' : Lanczos approximation - - Default : if the Fourier matrix is present: 'exact' otherwise - 'cheby' - order : Degree of the Chebyshev approximation - Default is 30 + c : ndarray + Transform coefficients. + method : 'exact', 'chebyshev', 'lanczos' + Whether to use the exact method (via the graph Fourier transform) + or the Chebyshev polynomial approximation. The Lanczos + approximation is not working yet. + order : int + Degree of the Chebyshev approximation. Returns ------- @@ -228,12 +221,6 @@ def synthesis(self, c, order=30, method=None, **kwargs): Nf = len(self.g) N = self.G.N - if not method: - if hasattr(self.G, 'U'): - method = 'exact' - else: - method = 'cheby' - if method == 'exact': fie = self.evaluate(self.G.e) Nv = np.shape(c)[1] @@ -249,7 +236,7 @@ def synthesis(self, c, order=30, method=None, **kwargs): fc *= np.tile(fie[:][i], (Nv, 1)).T s += igft(np.conjugate(self.G.U), fc) - elif method == 'cheby': + elif method == 'chebyshev': cheb_coeffs = approximations.compute_cheby_coeff(self, m=order, N=order+1) s = np.zeros((N, np.shape(c)[1])) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index d99c179d..db84f986 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -35,7 +35,7 @@ def _test_synthesis(self, f): Nf = len(f.g) if 1 < Nf < 10: S = self._generate_coefficients(f.G.N, Nf) - f.synthesis(S, method='cheby') + f.synthesis(S, method='chebyshev') f.synthesis(S, method='exact') self.assertRaises(NotImplementedError, f.synthesis, S, method='lanczos') @@ -44,7 +44,7 @@ def _test_methods(self, f): self.assertIs(f.G, self._G) f.analysis(self._signal, method='exact') - f.analysis(self._signal, method='cheby') + f.analysis(self._signal, method='chebyshev') # TODO np.testing.assert_allclose(c_exact, c_cheby) self.assertRaises(NotImplementedError, f.analysis, self._signal, method='lanczos') @@ -152,7 +152,7 @@ def test_approximations(self): f = filters.Heat(self._G) c_exact = f.analysis(self._signal, method='exact') - c_cheby = f.analysis(self._signal, method='cheby') + c_cheby = f.analysis(self._signal, method='chebyshev') np.testing.assert_allclose(c_exact, c_cheby) self.assertRaises(NotImplementedError, f.analysis, From 31c2cd6741c91703c68eeabfab44014824f7dc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 10:21:14 +0200 Subject: [PATCH 187/392] estimate_lmax is not an upper bound --- pygsp/graphs/graph.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e686c0d8..fdd827ad 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -758,7 +758,8 @@ def estimate_lmax(self, recompute=False): The result is cached and accessible by the :py:attr:`lmax` property. Exact value given by the eigendecomposition of the Laplacian, see - :func:`compute_fourier_basis`. + :func:`compute_fourier_basis`. That estimation is much faster than the + eigendecomposition. Parameters ---------- @@ -768,12 +769,14 @@ def estimate_lmax(self, recompute=False): Examples -------- >>> from pygsp import graphs - >>> G = graphs.Sensor() + >>> G = graphs.Logo() >>> G.compute_fourier_basis() - >>> lmax = G.lmax + >>> print('{:.2f}'.format(G.lmax)) + 13.78 + >>> G = graphs.Logo() >>> G.estimate_lmax(recompute=True) - >>> G.lmax > lmax # Upper bound. - True + >>> print('{:.2f}'.format(G.lmax)) + 13.92 """ if hasattr(self, '_lmax') and not recompute: From bbc8b468fc9c65649ea2a61bcadf9ced05ce3543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 10:22:48 +0200 Subject: [PATCH 188/392] test existence of Fourier basis on private attribute --- pygsp/operators/reduction.py | 4 ++-- pygsp/plotting.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/operators/reduction.py b/pygsp/operators/reduction.py index b7bec4a7..533aa8f0 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/operators/reduction.py @@ -238,7 +238,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 hasattr(Gs[i], '_U'): V = Gs[i].U[:, -1] else: V = linalg.eigs(Gs[i].L, 1)[1][:, 0] @@ -502,7 +502,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 not hasattr(Gs[0], '_e') or not hasattr(Gs[0], '_U') use_landweber = bool(kwargs.pop('use_landweber', def_ul)) reg_eps = float(kwargs.get('reg_eps', 0.005)) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 27d9eda0..9b3c3984 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -421,7 +421,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, if not isinstance(filters.g, list): filters.g = [filters.g] if plot_eigenvalues is None: - plot_eigenvalues = hasattr(G, 'e') + plot_eigenvalues = hasattr(G, '_e') if show_sum is None: show_sum = len(filters.g) > 1 if plot_name is None: From 4d074b9a70005702ca7b38f55fca6c4bee3ef00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 10:24:57 +0200 Subject: [PATCH 189/392] plotting: viewpoint for 3D matplotlib plots --- pygsp/graphs/nngraphs/bunny.py | 5 ++++- pygsp/plotting.py | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index d5dbf6fd..41e2124e 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -25,7 +25,10 @@ def __init__(self, **kwargs): plotting = {'vertex_size': 10, 'vertex_color': (1, 1, 1, 1), - 'edge_color': (.5, .5, .5, 1)} + 'edge_color': (.5, .5, .5, 1), + 'elevation': -89, + 'azimuth': 94, + 'distance': 7} super(Bunny, self).__init__(Xin=data['bunny'], epsilon=0.2, NNtype='radius', plotting=plotting, diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 9b3c3984..32b4cb37 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -256,6 +256,14 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name marker='o', s=vertex_size, c=G.plotting['vertex_color']) + if G.coords.shape[1] == 3: + try: + ax.view_init(elev=G.plotting['elevation'], + azim=G.plotting['azimuth']) + ax.dist = G.plotting['distance'] + except KeyError: + pass + # Save plot as PNG or show it in a window if savefig: plt.savefig(plot_name + '.png') @@ -598,6 +606,12 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], if G.coords.shape[1] == 3: ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], s=vertex_size, c=signal, zorder=2) + try: + ax.view_init(elev=G.plotting['elevation'], + azim=G.plotting['azimuth']) + ax.dist = G.plotting['distance'] + except KeyError: + pass # Save plot as PNG or show it in a window if savefig: From b79533d4c21c7f6886b286ceed27383ecd76f9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 10:26:35 +0200 Subject: [PATCH 190/392] filters: remove unused kwargs arguments --- pygsp/filters/filter.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index c696c08d..52ff522c 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -54,7 +54,7 @@ class Filter(object): """ - def __init__(self, G, filters, **kwargs): + def __init__(self, G, filters): self.G = G @@ -63,7 +63,7 @@ def __init__(self, G, filters, **kwargs): else: self.g = [filters] - def analysis(self, s, method='chebyshev', order=30, **kwargs): + def analysis(self, s, method='chebyshev', order=30): r""" Operator to analyse a filterbank @@ -142,7 +142,7 @@ def analysis(self, s, method='chebyshev', order=30, **kwargs): return c @utils.filterbank_handler - def evaluate(self, x, *args, **kwargs): + def evaluate(self, x, i=0): r""" Evaluation of the Filterbank @@ -168,18 +168,15 @@ def evaluate(self, x, *args, **kwargs): >>> eva = MH.evaluate(x) """ - i = kwargs.pop('i', 0) + return self.g[i](x) - fd = self.g[i](x) - return fd - - def inverse(self, c, **kwargs): + def inverse(self, c): r""" Not implemented yet. """ raise NotImplementedError - def synthesis(self, c, method='chebyshev', order=30, **kwargs): + def synthesis(self, c, method='chebyshev', order=30): r""" Synthesis operator of a filterbank @@ -260,7 +257,7 @@ def synthesis(self, c, method='chebyshev', order=30, **kwargs): return s - def approx(self, m, N, **kwargs): + def approx(self, m, N): r""" Not implemented yet. """ From 0403a25a9b5e66638f5d2d4ab179044d5b28ccc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 10:28:21 +0200 Subject: [PATCH 191/392] plotting: allow to pass an axis for matplotlib plots (useful for subplots) --- pygsp/plotting.py | 47 +++++++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 32b4cb37..3a753f0e 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -136,6 +136,9 @@ def plot_graph(G, default_qtg=True, **kwargs): show_plot : boolean whether to show the plot, i.e. call plt.show(). Only available with the matplotlib backend. + ax : matplotlib.axes + Axes to where to draw the graph. Optional, created if not passed. Only + available with the matplotlib backend. Examples -------- @@ -159,15 +162,12 @@ def plot_graph(G, default_qtg=True, **kwargs): 'install matplotlib or pyqtgraph.') -def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name=''): +def _plt_plot_graph(G, savefig=False, show_edges=None, + show_plot=True, plot_name='', ax=None): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) - if not plot_name: plot_name = u"Plot of {}".format(G.gtype) @@ -184,11 +184,15 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, show_plot=True, plot_name except KeyError: edge_color = np.array([255, 88, 41]) / 255. - # Matplotlib graph initialization in 2D and 3D - if G.coords.shape[1] == 2: - ax = fig.add_subplot(111) - elif G.coords.shape[1] == 3: - ax = fig.add_subplot(111, projection='3d') + if not ax: + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) + + if G.coords.shape[1] == 2: + ax = fig.add_subplot(111) + elif G.coords.shape[1] == 3: + ax = fig.add_subplot(111, projection='3d') if show_edges: ki, kj = np.nonzero(G.A) @@ -509,6 +513,9 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): whether the plot is saved as plot_name.png and plot_name.pdf (True) or shown in a window (False) (default False). Only available with the matplotlib backend. + ax : matplotlib.axes + Axes to where to draw the graph. Optional, created if not passed. Only + available with the matplotlib backend. Examples -------- @@ -535,11 +542,7 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], vertex_size=None, vertex_highlight=False, climits=None, colorbar=True, bar=False, bar_width=1, savefig=False, - show_plot=False, plot_name=None): - - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) + show_plot=False, plot_name=None, ax=None): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") @@ -554,11 +557,15 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], if plot_name is None: plot_name = "Signal plot of " + G.gtype - # Matplotlib graph initialization in 2D and 3D - if G.coords.shape[1] == 2: - ax = fig.add_subplot(111) - elif G.coords.shape[1] == 3: - ax = fig.add_subplot(111, projection='3d') + if not ax: + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) + + if G.coords.shape[1] == 2: + ax = fig.add_subplot(111) + elif G.coords.shape[1] == 3: + ax = fig.add_subplot(111, projection='3d') if show_edges: ki, kj = np.nonzero(G.A) From 2b5403afc6dac332a67ad2f05e4735f40dccd63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 12:44:42 +0200 Subject: [PATCH 192/392] Filter.Nf: number of filters in filterbank --- pygsp/filters/filter.py | 31 ++++++++++++++++--------------- pygsp/tests/test_filters.py | 5 ++--- pygsp/utils.py | 13 +++++++++---- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 52ff522c..90d8f128 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -37,6 +37,8 @@ class Filter(object): A (list of) function defining the filterbank. One function per filter. Either passed by the user when instantiating the base class, either constructed by the derived classes. + Nf : int + Number of filters in the filterbank. Examples -------- @@ -63,6 +65,8 @@ def __init__(self, G, filters): else: self.g = [filters] + self.Nf = len(self.g) + def analysis(self, s, method='chebyshev', order=30): r""" Operator to analyse a filterbank @@ -106,19 +110,18 @@ def analysis(self, s, method='chebyshev', order=30): # c = approximations.lanczos_op(self, s, order=order) elif method == 'exact': - Nf = len(self.g) # nb of filters N = self.G.N # nb of nodes try: Ns = np.shape(s)[1] # nb signals - c = np.zeros((N * Nf, Ns)) + c = np.zeros((N * self.Nf, Ns)) is2d = True except IndexError: - c = np.zeros((N * Nf)) + c = np.zeros((N * self.Nf)) is2d = False fie = self.evaluate(self.G.e) - if Nf == 1: + if self.Nf == 1: if is2d: fs = np.tile(fie, (Ns, 1)).T * gft(self.G, s) return igft(self.G, fs) @@ -127,7 +130,7 @@ def analysis(self, s, method='chebyshev', order=30): return igft(self.G, fs) else: tmpN = np.arange(N, dtype=int) - for i in range(Nf): + for i in range(self.Nf): if is2d: fs = gft(self.G, s) fs *= np.tile(fie[i], (Ns, 1)).T @@ -215,7 +218,7 @@ def synthesis(self, c, method='chebyshev', order=30): >>> Sf = Wk.synthesis(S) """ - Nf = len(self.g) + N = self.G.N if method == 'exact': @@ -224,11 +227,11 @@ def synthesis(self, c, method='chebyshev', order=30): s = np.zeros((N, Nv)) tmpN = np.arange(N, dtype=int) - if Nf == 1: + if self.Nf == 1: fc = np.tile(fie, (Nv, 1)).T * gft(self.G, c[tmpN]) s += igft(np.conjugate(self.G.U), fc) else: - for i in range(Nf): + for i in range(self.Nf): fc = gft(self.G, c[N * i + tmpN]) fc *= np.tile(fie[:][i], (Nv, 1)).T s += igft(np.conjugate(self.G.U), fc) @@ -239,7 +242,7 @@ def synthesis(self, c, method='chebyshev', order=30): s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) - for i in range(Nf): + for i in range(self.Nf): s += approximations.cheby_op(self.G, cheb_coeffs[i], c[i * N + tmpN]) @@ -248,7 +251,7 @@ def synthesis(self, c, method='chebyshev', order=30): s = np.zeros((N, np.shape(c)[1])) tmpN = np.arange(N, dtype=int) - for i in range(Nf): + for i in range(self.Nf): s += approximations.lanczos_op(self.G, self.g[i], c[i * N + tmpN], order=order) @@ -333,12 +336,11 @@ def filterbank_matrix(self): if N > 2000: _logger.warning('Creating a big matrix, you can use other means.') - Nf = len(self.g) Ft = self.analysis(np.identity(N)) F = np.zeros(np.shape(Ft.T)) tmpN = np.arange(N, dtype=int) - for i in range(Nf): + for i in range(self.Nf): F[:, N * i + tmpN] = Ft[N * i + tmpN] return F @@ -351,7 +353,7 @@ def can_dual_func(g, n, x): # Nshape = np.shape(x) x = np.ravel(x) N = np.shape(x)[0] - M = len(g.g) + M = g.Nf gcoeff = g.evaluate(x).T s = np.zeros((N, M)) @@ -363,8 +365,7 @@ def can_dual_func(g, n, x): gdual = copy.deepcopy(self) - Nf = len(self.g) - for i in range(Nf): + for i in range(self.Nf): gdual.g[i] = lambda x, ind=i: can_dual_func(self, ind, copy.deepcopy(x)) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index db84f986..adbbc32d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -32,9 +32,8 @@ def _generate_coefficients(self, N, Nf, vertex_delta=83): return S def _test_synthesis(self, f): - Nf = len(f.g) - if 1 < Nf < 10: - S = self._generate_coefficients(f.G.N, Nf) + if 1 < f.Nf < 10: + S = self._generate_coefficients(f.G.N, f.Nf) f.synthesis(S, method='chebyshev') f.synthesis(S, method='exact') self.assertRaises(NotImplementedError, f.synthesis, S, diff --git a/pygsp/utils.py b/pygsp/utils.py index b3ed7cb0..fa2167d2 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -60,19 +60,24 @@ def inner(G, *args, **kwargs): def filterbank_handler(func): - @functools.wraps(func) + + # Preserve documentation of func. + functools.wraps(func) def inner(f, *args, **kwargs): + if 'i' in kwargs: return func(f, *args, **kwargs) - if len(f.g) <= 1: + elif f.Nf <= 1: return func(f, *args, **kwargs) - elif len(f.g) > 1: + + else: output = [] - for i in range(len(f.g)): + for i in range(f.Nf): output.append(func(f, *args, i=i, **kwargs)) return output + return inner From 3da9968e9955d3b5f1c12d7597ca610651c95e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 13:10:35 +0200 Subject: [PATCH 193/392] doc: shorter references --- pygsp/__init__.py | 14 ++++----- pygsp/filters/__init__.py | 42 +++++++++++++-------------- pygsp/graphs/__init__.py | 58 ++++++++++++++++++------------------- pygsp/operators/__init__.py | 36 +++++++++++------------ 4 files changed, 75 insertions(+), 75 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 837ce074..3db94627 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -4,16 +4,16 @@ The :mod:`pygsp` package is mainly organized around the following three modules: -* :mod:`pygsp.graphs` to create and manipulate various kinds of graphs, -* :mod:`pygsp.filters` to create and manipulate various graph filters, -* :mod:`pygsp.operators` to apply various operators to graph signals. +* :mod:`.graphs` to create and manipulate various kinds of graphs, +* :mod:`.filters` to create and manipulate various graph filters, +* :mod:`.operators` to apply various operators to graph signals. Moreover, the following modules provide additional functionality: -* :mod:`pygsp.plotting` to plot, -* :mod:`pygsp.features` to compute features on graphs, -* :mod:`pygsp.optimization` to help solving convex optimization problems, -* :mod:`pygsp.utils` for various utilities. +* :mod:`.plotting` to plot, +* :mod:`.features` to compute features on graphs, +* :mod:`.optimization` to help solving convex optimization problems, +* :mod:`.utils` for various utilities. """ diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 697f9e5e..70e50402 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -8,32 +8,32 @@ filters, usually centered around different frequencies, applied to a single graph. -See the :class:`pygsp.filters.Filter` base class for the documentation of the +See the :class:`Filter` base class for the documentation of the interface to the filter object. Derived classes implement various common graph filters. **Filterbank of N filters** -* :class:`pygsp.filters.Abspline` -* :class:`pygsp.filters.Gabor` -* :class:`pygsp.filters.HalfCosine` -* :class:`pygsp.filters.Itersine` -* :class:`pygsp.filters.MexicanHat` -* :class:`pygsp.filters.Meyer` -* :class:`pygsp.filters.SimpleTf` -* :class:`pygsp.filters.WarpedTranslates` +* :class:`Abspline` +* :class:`Gabor` +* :class:`HalfCosine` +* :class:`Itersine` +* :class:`MexicanHat` +* :class:`Meyer` +* :class:`SimpleTf` +* :class:`WarpedTranslates` **Filterbank of 2 filters: low pass and high pass** -* :class:`pygsp.filters.Regular` -* :class:`pygsp.filters.Held` -* :class:`pygsp.filters.Simoncelli` -* :class:`pygsp.filters.Papadakis` +* :class:`Regular` +* :class:`Held` +* :class:`Simoncelli` +* :class:`Papadakis` **Low pass filter** -* :class:`pygsp.filters.Heat` -* :class:`pygsp.filters.Expwin` +* :class:`Heat` +* :class:`Expwin` Moreover, two approximation methods are provided for fast filtering. The computational complexity of filtering with those approximations is linear with @@ -43,15 +43,15 @@ **Chebyshev polynomials** -* :class:`pygsp.filters.compute_cheby_coeff` -* :class:`pygsp.filters.compute_jackson_cheby_coeff` -* :class:`pygsp.filters.cheby_op` -* :class:`pygsp.filters.cheby_rect` +* :func:`compute_cheby_coeff` +* :func:`compute_jackson_cheby_coeff` +* :func:`cheby_op` +* :func:`cheby_rect` **Lanczos algorithm** -* :class:`pygsp.filters.lanczos` -* :class:`pygsp.filters.lanczos_op` +* :func:`lanczos` +* :func:`lanczos_op` """ diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index e2547771..23bf4d0f 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -5,40 +5,40 @@ object is either constructed from an adjacency matrix, or by instantiating one of the built-in graph models. -The :class:`pygsp.graphs.Graph` base class allows to construct a graph object -from any adjacency matrix and provides a common interface to that object. +The :class:`Graph` base class allows to construct a graph object from any +adjacency matrix and provides a common interface to that object. Derived classes implement various graph models. -* :class:`pygsp.graphs.Airfoil` -* :class:`pygsp.graphs.BarabasiAlbert` -* :class:`pygsp.graphs.Comet` -* :class:`pygsp.graphs.Community` -* :class:`pygsp.graphs.DavidSensorNet` -* :class:`pygsp.graphs.ErdosRenyi` -* :class:`pygsp.graphs.FullConnected` -* :class:`pygsp.graphs.Grid2d` -* :class:`pygsp.graphs.Logo` -* :class:`pygsp.graphs.LowStretchTree` -* :class:`pygsp.graphs.Minnesota` -* :class:`pygsp.graphs.Path` -* :class:`pygsp.graphs.RandomRegular` -* :class:`pygsp.graphs.RandomRing` -* :class:`pygsp.graphs.Ring` -* :class:`pygsp.graphs.Sensor` -* :class:`pygsp.graphs.StochasticBlockModel` -* :class:`pygsp.graphs.SwissRoll` -* :class:`pygsp.graphs.Torus` +* :class:`Airfoil` +* :class:`BarabasiAlbert` +* :class:`Comet` +* :class:`Community` +* :class:`DavidSensorNet` +* :class:`ErdosRenyi` +* :class:`FullConnected` +* :class:`Grid2d` +* :class:`Logo` +* :class:`LowStretchTree` +* :class:`Minnesota` +* :class:`Path` +* :class:`RandomRegular` +* :class:`RandomRing` +* :class:`Ring` +* :class:`Sensor` +* :class:`StochasticBlockModel` +* :class:`SwissRoll` +* :class:`Torus` -Derived classes from :class:`pygsp.graphs.NNGraph` implement nearest-neighbors -graphs constructed from point clouds. +Derived classes from :class:`NNGraph` implement nearest-neighbors graphs +constructed from point clouds. -* :class:`pygsp.graphs.Bunny` -* :class:`pygsp.graphs.Cube` -* :class:`pygsp.graphs.ImgPatches` -* :class:`pygsp.graphs.Grid2dImgPatches` -* :class:`pygsp.graphs.Sphere` -* :class:`pygsp.graphs.TwoMoons` +* :class:`Bunny` +* :class:`Cube` +* :class:`ImgPatches` +* :class:`Grid2dImgPatches` +* :class:`Sphere` +* :class:`TwoMoons` """ diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index dd01980b..bfc36095 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -5,33 +5,33 @@ **Differential operators** -* :func:`pygsp.operators.grad`: compute the gradient of a graph signal -* :func:`pygsp.operators.div`: compute the divergence of a graph signal +* :func:`grad`: compute the gradient of a graph signal +* :func:`div`: compute the divergence of a graph signal **Transforms** (frequency and vertex-frequency) -* :func:`pygsp.operators.gft`: graph Fourier transform (GFT) -* :func:`pygsp.operators.igft`: inverse graph Fourier transform -* :func:`pygsp.operators.gft_windowed`: windowed GFT -* :func:`pygsp.operators.gft_windowed_gabor`: Gabor windowed GFT -* :func:`pygsp.operators.gft_windowed_normalized`: normalized windowed GFT +* :func:`gft`: graph Fourier transform (GFT) +* :func:`igft`: inverse graph Fourier transform +* :func:`gft_windowed`: windowed GFT +* :func:`gft_windowed_gabor`: Gabor windowed GFT +* :func:`gft_windowed_normalized`: normalized windowed GFT **Localization** -* :func:`pygsp.operators.localize`: localize a kernel -* :func:`pygsp.operators.modulate`: generalized modulation operator -* :func:`pygsp.operators.translate`: generalized translation operator +* :func:`localize`: localize a kernel +* :func:`modulate`: generalized modulation operator +* :func:`translate`: generalized translation operator **Reduction** Functionalities for the reduction of graphs' vertex set while keeping the graph structure. -* :func:`pygsp.operators.tree_multiresolution`: compute a multiresolution of trees -* :func:`pygsp.operators.graph_multiresolution`: compute a pyramid of graphs -* :func:`pygsp.operators.kron_reduction`: compute the Kron reduction -* :func:`pygsp.operators.pyramid_analysis`: analysis operator for graph pyramid -* :func:`pygsp.operators.pyramid_synthesis`: synthesis operator for graph pyramid -* :func:`pygsp.operators.pyramid_cell2coeff`: keep only the necessary coefficients -* :func:`pygsp.operators.interpolate`: interpolate a signal -* :func:`pygsp.operators.graph_sparsify`: sparsify a graph +* :func:`tree_multiresolution`: compute a multiresolution of trees +* :func:`graph_multiresolution`: compute a pyramid of graphs +* :func:`kron_reduction`: compute the Kron reduction +* :func:`pyramid_analysis`: analysis operator for graph pyramid +* :func:`pyramid_synthesis`: synthesis operator for graph pyramid +* :func:`pyramid_cell2coeff`: keep only the necessary coefficients +* :func:`interpolate`: interpolate a signal +* :func:`graph_sparsify`: sparsify a graph """ From 659d30935dc959bef529f8944e79baa280879e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 16:00:36 +0200 Subject: [PATCH 194/392] improve Filter.estimate_frame_bounds --- pygsp/filters/filter.py | 81 +++++++++++++++++++++++++++---------- pygsp/tests/test_filters.py | 2 +- 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 90d8f128..c8c07908 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -272,44 +272,83 @@ def tighten(self): """ raise NotImplementedError - def filterbank_bounds(self, N=999, bounds=None): + def estimate_frame_bounds(self, min=0, max=None, N=1000, + use_eigenvalues=False): r""" - Compute approximate frame bounds for a filterbank. + Compute approximate frame bounds for the filterbank. + + The frame bounds are estimated using the vector :code:`np.linspace(min, + max, N)` with min=0 and max=G.lmax by default. The eigenvalues G.e can + be used instead if you set use_eigenvalues to True. Parameters ---------- - bounds : interval to compute the bound. - Given in an ndarray: np.array([xmin, xnmax]). - By default, bounds is None and filtering is bounded - by the eigenvalues of G. - N : Number of point for the line search - Default is 999 + min : float + The lowest value the filter bank is evaluated at. By default + filtering is bounded by the eigenvalues of G, i.e. min = 0. + max : float + The largest value the filter bank is evaluated at. By default + filtering is bounded by the eigenvalues of G, i.e. max = G.lmax. + N : int + Number of points where the filter bank is evaluated. + Default is 1000. + use_eigenvalues : bool + Set to True to use the Laplacian eigenvalues instead. Returns ------- - lower : Filterbank lower bound - upper : Filterbank upper bound + A : float + Lower frame bound of the filter bank. + B : float + Upper frame bound of the filter bank. Examples -------- - >>> import numpy as np >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> MH = filters.MexicanHat(G) - >>> bounds = MH.filterbank_bounds() - >>> print('lower={:.3f}, upper={:.3f}'.format(bounds[0], bounds[1])) - lower=0.178, upper=0.270 + >>> G.estimate_lmax() + >>> f = filters.MexicanHat(G) + + Bad estimation: + + >>> A, B = f.estimate_frame_bounds(min=1, max=20, N=100) + >>> print('A={:.3f}, B={:.3f}'.format(A, B)) + A=0.126, B=0.270 + + Better estimation: + + >>> A, B = f.estimate_frame_bounds() + >>> print('A={:.3f}, B={:.3f}'.format(A, B)) + A=0.177, B=0.270 + + Best estimation: + + >>> G.compute_fourier_basis() + >>> A, B = f.estimate_frame_bounds(use_eigenvalues=True) + >>> print('A={:.3f}, B={:.3f}'.format(A, B)) + A=0.178, B=0.270 + + The Itersine filter bank defines a tight frame: + + >>> f = filters.Itersine(G) + >>> G.compute_fourier_basis() + >>> A, B = f.estimate_frame_bounds(use_eigenvalues=True) + >>> print('A={:.3f}, B={:.3f}'.format(A, B)) + A=1.000, B=1.000 """ - if bounds: - xmin, xmax = bounds - rng = np.linspace(xmin, xmax, N) + + if max is None: + max = self.G.lmax + + if use_eigenvalues: + x = self.G.e else: - rng = self.G.e + x = np.linspace(min, max, N) - sum_filters = np.sum(np.abs(np.power(self.evaluate(rng), 2)), axis=0) + sum_filters = np.sum(np.abs(np.power(self.evaluate(x), 2)), axis=0) - return np.min(sum_filters), np.max(sum_filters) + return sum_filters.min(), sum_filters.max() def filterbank_matrix(self): r""" diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index adbbc32d..278c3393 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -51,7 +51,7 @@ def _test_methods(self, f): self._test_synthesis(f) f.evaluate(np.ones(10)) - f.filterbank_bounds() + f.estimate_frame_bounds() # f.filterbank_matrix() TODO: too much memory # TODO: f.can_dual() From 152bc73863844703fd1ea3a7554900b6a4cc9d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 16:50:07 +0200 Subject: [PATCH 195/392] fix Meyer filterbank --- doc/references.bib | 9 ++++++ pygsp/filters/meyer.py | 65 +++++++++++++++++++++--------------------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/doc/references.bib b/doc/references.bib index 9b070a3e..580c76aa 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -189,3 +189,12 @@ @inproceedings{kim2003randomregulargraphs booktitle={Proceedings of the thirty-fifth annual ACM symposium on Theory of computing}, year={2003} } + +@inproceedings{leonardi2011wavelet, + title={Wavelet frames on graphs defined by fMRI functional connectivity}, + author={Leonardi, Nora and Van De Ville, Dimitri}, + booktitle={Biomedical Imaging: From Nano to Macro, 2011 IEEE International Symposium on}, + pages={2136--2139}, + year={2011}, + organization={IEEE} +} diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index ca675ae2..859e02fa 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -17,7 +17,14 @@ class Meyer(Filter): ---------- G : graph Nf : int - Number of filters from 0 to lmax (default = 6) + Number of filters from 0 to lmax (default = 6). + scales : ndarray + Vector of scales to be used (default: log scale). + + References + ---------- + Use of this kernel for SGWT proposed by Nora Leonardi and Dimitri Van De + Ville in :cite:`leonardi2011wavelet`. Examples -------- @@ -27,59 +34,51 @@ class Meyer(Filter): """ - def __init__(self, G, Nf=6, **kwargs): - - if not hasattr(G, 't'): - G.t = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) + def __init__(self, G, Nf=6, scales=None, **kwargs): - if len(G.t) >= Nf - 1: - _logger.warning('You have specified more scales than ' - 'the number of scales minus 1') + if scales is None: + scales = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) - t = G.t + if len(scales) != Nf - 1: + raise ValueError('The number of scales should be equal to ' + 'the number of filters minus 1.') - g = [lambda x: kernel_meyer(t[0] * x, 'sf')] + g = [lambda x: kernel_meyer(scales[0] * x, 'scaling_function')] for i in range(Nf - 1): - g.append(lambda x, ind=i: kernel_meyer(t[ind] * x, 'wavelet')) + g.append(lambda x: kernel_meyer(scales[i] * x, 'wavelet')) - def kernel_meyer(x, kerneltype): + def kernel_meyer(x, kernel_type): r""" Evaluates Meyer function and scaling function - Parameters - ---------- - x : ndarray - Array of independant variables values - kerneltype : str - Can be either 'sf' or 'wavelet' - - Returns - ------- - r : ndarray - + * meyer wavelet kernel: supported on [2/3,8/3] + * meyer scaling function kernel: supported on [0,4/3] """ - x = np.array(x) + x = np.asarray(x) l1 = 2/3. - l2 = 4/3. - l3 = 8/3. + l2 = 4/3. # 2*l1 + l3 = 8/3. # 4*l1 - v = lambda x: x ** 4. * (35 - 84*x + 70*x**2 - 20*x**3) + def v(x): + return x**4 * (35 - 84*x + 70*x**2 - 20*x**3) r1ind = (x < l1) - r2ind = (x >= l1)*(x < l2) - r3ind = (x >= l2)*(x < l3) + r2ind = (x >= l1) * (x < l2) + r3ind = (x >= l2) * (x < l3) - r = np.empty(x.shape) - if kerneltype is 'sf': + # as we initialize r with zero, computed function will implicitly + # be zero for all x not in one of the three regions defined above + r = np.zeros(x.shape) + if kernel_type == 'scaling_function': r[r1ind] = 1 r[r2ind] = np.cos((np.pi/2) * v(np.abs(x[r2ind])/l1 - 1)) - elif kerneltype is 'wavelet': + elif kernel_type == 'wavelet': r[r2ind] = np.sin((np.pi/2) * v(np.abs(x[r2ind])/l1 - 1)) r[r3ind] = np.cos((np.pi/2) * v(np.abs(x[r3ind])/l2 - 1)) else: - raise TypeError('Unknown kernel type ', kerneltype) + raise ValueError('Unknown kernel type {}'.format(kernel_type)) return r From dee5f87f870ea3807124a2308c4b6609b3bccdfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 17:07:55 +0200 Subject: [PATCH 196/392] Filter.compute_frame: rename, document and test --- pygsp/filters/filter.py | 43 +++++++++++++++++++++++++++++-------- pygsp/tests/test_filters.py | 24 +++++++++++++++------ 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index c8c07908..dac5eb92 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -350,33 +350,58 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, return sum_filters.min(), sum_filters.max() - def filterbank_matrix(self): + def compute_frame(self, **kwargs): r""" - Create the matrix of the filterbank frame. + Compute the frame associated with the filter bank. - This function creates the matrix associated to the filterbank g. - The size of the matrix is MN x N, where M is the number of filters. + The size of the returned matrix operator :math:`D` is N x MN, where M + is the number of filters and N the number of nodes. Multiplying this + matrix with a set of signals is equivalent to analyzing them with the + associated filterbank. + + Parameters + ---------- + kwargs: dict + Parameters to be passed to the :meth:`analysis` method. Returns ------- - F : Frame + frame : ndarray + Matrix of size N x MN. + + See also + -------- + analysis: more efficient way to filter signals Examples -------- >>> import numpy as np >>> from pygsp import graphs, filters + >>> >>> G = graphs.Logo() - >>> MH = filters.MexicanHat(G) - >>> matrix = MH.filterbank_matrix() + >>> G.estimate_lmax() + >>> + >>> f = filters.MexicanHat(G) + >>> frame = f.compute_frame() + >>> print('{} nodes, matrix is {} x {}'.format(G.N, *frame.shape)) + 1130 nodes, matrix is 1130 x 6780 + >>> + >>> s = np.random.uniform(size=G.N) + >>> c1 = frame.T.dot(s) + >>> c2 = f.analysis(s) + >>> + >>> np.linalg.norm(c1 - c2) < 1e-10 + True """ + N = self.G.N if N > 2000: _logger.warning('Creating a big matrix, you can use other means.') - Ft = self.analysis(np.identity(N)) - F = np.zeros(np.shape(Ft.T)) + Ft = self.analysis(np.identity(N), **kwargs) + F = np.empty(Ft.T.shape) tmpN = np.arange(N, dtype=int) for i in range(self.Nf): diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 278c3393..2a5fcff7 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -17,6 +17,7 @@ class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls._G = graphs.Logo() + cls._G.compute_fourier_basis() rs = np.random.RandomState(42) cls._signal = rs.uniform(size=cls._G.N) @@ -42,17 +43,27 @@ def _test_synthesis(self, f): def _test_methods(self, f): self.assertIs(f.G, self._G) - f.analysis(self._signal, method='exact') - f.analysis(self._signal, method='chebyshev') - # TODO np.testing.assert_allclose(c_exact, c_cheby) + c_exact = f.analysis(self._signal, method='exact') + c_cheby = f.analysis(self._signal, method='chebyshev') + self.assertEqual(c_exact.shape, c_cheby.shape) + # TODO: a bit far for some filterbanks. + # np.testing.assert_allclose(c_exact, c_cheby) self.assertRaises(NotImplementedError, f.analysis, self._signal, method='lanczos') + if f.Nf < 10: + F = f.compute_frame(method='chebyshev') + c_frame = F.T.dot(self._signal) + np.testing.assert_allclose(c_frame, c_cheby) + F = f.compute_frame(method='exact') + c_frame = F.T.dot(self._signal) + np.testing.assert_allclose(c_frame, c_exact) + self._test_synthesis(f) - f.evaluate(np.ones(10)) + f.evaluate(self._G.e) - f.estimate_frame_bounds() - # f.filterbank_matrix() TODO: too much memory + # Minimum is not 0 to avoid division by 0 in expwin. + f.estimate_frame_bounds(min=0.01) # TODO: f.can_dual() @@ -64,6 +75,7 @@ def test_custom_filter(self): def _filter(x): return x / (1. + x) f = filters.Filter(self._G, filters=_filter) + self.assertEqual(f.Nf, 1) self.assertIs(f.g[0], _filter) self._test_methods(f) From 1ae60ba9a630e8ec8973f25d72e50f52d47c74c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 18:33:36 +0200 Subject: [PATCH 197/392] reproducible sensor graph --- pygsp/graphs/sensor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 4e2e7201..101ddaf9 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -25,6 +25,8 @@ class Sensor(Graph): To distribute the points more evenly (default = False) connected : bool To force the graph to be connected (default = True) + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -34,12 +36,13 @@ class Sensor(Graph): """ def __init__(self, N=64, Nc=2, regular=False, n_try=50, - distribute=False, connected=True, **kwargs): + distribute=False, connected=True, seed=None, **kwargs): self.Nc = Nc self.regular = regular self.n_try = n_try self.distribute = distribute + self.seed = seed self.logger = utils.build_logger(__name__, **kwargs) @@ -88,18 +91,20 @@ def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): XCoords = np.zeros((N, 1)) YCoords = np.zeros((N, 1)) + rs = np.random.RandomState(self.seed) + if param_distribute: mdim = int(np.ceil(np.sqrt(N))) for i in range(mdim): for j in range(mdim): if i*mdim + j < N: - XCoords[i*mdim + j] = np.array((i + np.random.rand()) / mdim) - YCoords[i*mdim + j] = np.array((j + np.random.rand()) / mdim) + XCoords[i*mdim + j] = np.array((i + rs.rand()) / mdim) + YCoords[i*mdim + j] = np.array((j + rs.rand()) / mdim) # take random coordinates in a 1 by 1 square else: - XCoords = np.random.rand(N, 1) - YCoords = np.random.rand(N, 1) + XCoords = rs.rand(N, 1) + YCoords = rs.rand(N, 1) coords = np.concatenate((XCoords, YCoords), axis=1) From d1670806d5b54bab87338da6f0cd97224dff9e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 18:34:17 +0200 Subject: [PATCH 198/392] filters: much better doc for evaluate, analyze, synthesize --- pygsp/filters/filter.py | 146 ++++++++++++++++++++++++++++++---------- pygsp/utils.py | 2 +- 2 files changed, 111 insertions(+), 37 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index dac5eb92..9e8d4fa7 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -69,12 +69,28 @@ def __init__(self, G, filters): def analysis(self, s, method='chebyshev', order=30): r""" - Operator to analyse a filterbank + Analyze, or filter, a signal with the filter bank. + + The method computes the transform coefficients of a signal :math:`s`, + where the atoms of the transform dictionary are generalized + translations of each graph spectral filter to each vertex on the graph: + + .. math:: c = D^* s, + + where the columns of :math:`D` are :math:`g_{i,m} = T_i g_m` and + :math:`T_i` is a generalized translation operator applied to each + filter :math:`\hat{g}_m(\cdot)`. Each column of :math:`c` is the + response of the signal to one filter. + + In other words, this function is applying the analysis operator + :math:`D^*` associated with the frame defined by the filter bank to the + signals. Parameters ---------- s : ndarray - Graph signals to analyse. + Graph signals to analyze, a matrix of size N x Ns where N is the + number of nodes and Ns the number of signals. method : 'exact', 'chebyshev', 'lanczos' Whether to use the exact method (via the graph Fourier transform) or the Chebyshev polynomial approximation. The Lanczos @@ -85,22 +101,33 @@ def analysis(self, s, method='chebyshev', order=30): Returns ------- c : ndarray - Transform coefficients + Transform coefficients, a matrix of size Nf*N x Ns where Nf is the + number of filters, N the number of nodes, and Ns the number of + signals. + + See Also + -------- + synthesis : adjoint of the analysis operator + + References + ---------- + See :cite:`hammond2011wavelets` for more details. Examples -------- + Create a smooth graph signal by low-pass filtering white noise. + >>> import numpy as np >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> MH = filters.MexicanHat(G) - >>> x = np.arange(G.N**2).reshape(G.N, G.N) - >>> co = MH.analysis(x) - - References - ---------- - See :cite:`hammond2011wavelets` + >>> G.estimate_lmax() + >>> s1 = np.random.uniform(size=(G.N, 4)) + >>> s2 = filters.Expwin(G).analysis(s1) + >>> G.plot_signal(s1[:, 0]) + >>> G.plot_signal(s2[:, 0]) """ + if method == 'chebyshev': cheb_coef = approximations.compute_cheby_coeff(self, m=order) c = approximations.cheby_op(self.G, cheb_coef, s) @@ -147,30 +174,35 @@ def analysis(self, s, method='chebyshev', order=30): @utils.filterbank_handler def evaluate(self, x, i=0): r""" - Evaluation of the Filterbank + Evaluate the response of the filter bank at frequencies x. Parameters ---------- - x = ndarray - Data - i = int - Indice of the filter to evaluate + x : ndarray + Graph frequencies at which to evaluate the filter. + i : int + Index of the filter to evaluate. Default: evaluate all of them. Returns ------- - fd = ndarray - Response of the filter + y : ndarray + Responses of the filters. Examples -------- - >>> import numpy as np + Frequency response of a low-pass filter. + + >>> import matplotlib.pyplot as plt >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> MH = filters.MexicanHat(G) - >>> x = np.arange(2) - >>> eva = MH.evaluate(x) + >>> f = filters.Expwin(G) + >>> G.compute_fourier_basis() + >>> y = f.evaluate(G.e) + >>> plt.plot(G.e, y) # doctest: +ELLIPSIS + [] """ + return self.g[i](x) def inverse(self, c): @@ -181,12 +213,28 @@ def inverse(self, c): def synthesis(self, c, method='chebyshev', order=30): r""" - Synthesis operator of a filterbank + Synthesize a signal from its filter bank coefficients. + + The method synthesizes a signal :math:`s` from its coefficients + :math:`c`, where the atoms of the transform dictionary are generalized + translations of each graph spectral filter to each vertex on the graph: + + .. math:: s = D c, + + where the columns of :math:`D` are :math:`g_{i,m} = T_i g_m` and + :math:`T_i` is a generalized translation operator applied to each + filter :math:`\hat{g}_m(\cdot)`. + + In other words, this function is applying the synthesis operator + :math:`D` associated with the frame defined from the filter bank to the + coefficients. Parameters ---------- c : ndarray - Transform coefficients. + Transform coefficients, a matrix of size Nf*N x Ns where Nf is the + number of filters, N the number of nodes, and Ns the number of + signals. method : 'exact', 'chebyshev', 'lanczos' Whether to use the exact method (via the graph Fourier transform) or the Chebyshev polynomial approximation. The Lanczos @@ -196,7 +244,13 @@ def synthesis(self, c, method='chebyshev', order=30): Returns ------- - signal : synthesis signal + s : ndarray + Synthesized graph signals, a matrix of size N x Ns where N is the + number of nodes and Ns the number of signals. + + See Also + -------- + analysis : adjoint of the synthesis operator References ---------- @@ -204,18 +258,39 @@ def synthesis(self, c, method='chebyshev', order=30): Examples -------- + >>> import numpy as np >>> from pygsp import graphs, filters - >>> G = graphs.Logo() - >>> Nf = 6 - >>> - >>> vertex_delta = 83 - >>> S = np.zeros((G.N * Nf, Nf)) - >>> S[vertex_delta] = 1 - >>> for i in range(Nf): - ... S[vertex_delta + i * G.N, i] = 1 - >>> - >>> Wk = filters.MexicanHat(G, Nf) - >>> Sf = Wk.synthesis(S) + >>> G = graphs.Sensor(30, seed=42) + >>> G.estimate_lmax() + + Localized smooth signal: + + >>> s1 = np.zeros((G.N, 1)) + >>> s1[13] = 1 + >>> s1 = filters.Heat(G, tau=3).analysis(s1) + + Filter and reconstruct our signal: + + >>> g = filters.MexicanHat(G, Nf=4) + >>> c = g.analysis(s1) + >>> s2 = g.synthesis(c) + + Look how well we were able to reconstruct: + + >>> g.plot() + >>> G.plot_signal(s1[:, 0]) + >>> G.plot_signal(s2[:, 0]) + >>> print('{:.2f}'.format(np.linalg.norm(s1 - s2))) + 0.30 + + Perfect reconstruction with Itersine, a tight frame: + + >>> g = filters.Itersine(G) + >>> c = g.analysis(s1) + >>> s2 = g.synthesis(c) + >>> err = np.linalg.norm(s1 - s2) + >>> print('{:.2f}'.format(np.linalg.norm(s1 - s2))) + 0.00 """ @@ -331,7 +406,6 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, The Itersine filter bank defines a tight frame: >>> f = filters.Itersine(G) - >>> G.compute_fourier_basis() >>> A, B = f.estimate_frame_bounds(use_eigenvalues=True) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) A=1.000, B=1.000 diff --git a/pygsp/utils.py b/pygsp/utils.py index fa2167d2..9f37649a 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -62,7 +62,7 @@ def inner(G, *args, **kwargs): def filterbank_handler(func): # Preserve documentation of func. - functools.wraps(func) + @functools.wraps(func) def inner(f, *args, **kwargs): From 0eb5473ca0c6fde021f46fbd252b2cef9eb9aed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 22 Aug 2017 18:35:57 +0200 Subject: [PATCH 199/392] filters module doc: links to key methods, short explanations --- pygsp/filters/__init__.py | 47 ++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 70e50402..c0d70a74 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -8,32 +8,39 @@ filters, usually centered around different frequencies, applied to a single graph. -See the :class:`Filter` base class for the documentation of the -interface to the filter object. Derived classes implement various common graph -filters. +The :class:`Filter` base class implements a common interface to all filters: -**Filterbank of N filters** +* :meth:`Filter.evaluate`: evaluate frequency response of the filterbank +* :meth:`Filter.analysis`: compute signal response to the filterbank +* :meth:`Filter.synthesis`: synthesize signal from response +* :meth:`Filter.compute_frame`: return a matrix operator +* :meth:`Filter.estimate_frame_bounds`: estimate lower and upper frame bounds +* :meth:`Filter.plot`: plot the filter frequency response -* :class:`Abspline` -* :class:`Gabor` -* :class:`HalfCosine` -* :class:`Itersine` -* :class:`MexicanHat` -* :class:`Meyer` -* :class:`SimpleTf` -* :class:`WarpedTranslates` +Then, derived classes implement various common graph filters. -**Filterbank of 2 filters: low pass and high pass** +**Filter banks of N filters** -* :class:`Regular` -* :class:`Held` -* :class:`Simoncelli` -* :class:`Papadakis` +* :class:`Abspline`: design a absspline filter bank +* :class:`Gabor`: design a Gabor filter bank +* :class:`HalfCosine`: design a half cosine filter bank (tight frame) +* :class:`Itersine`: design an itersine filter bank (tight frame) +* :class:`MexicanHat`: design a mexican hat filter bank +* :class:`Meyer`: design a Meyer filter bank +* :class:`SimpleTf`: design a simple tight frame filter bank +* :class:`WarpedTranslates`: design a filter bank with a warping function -**Low pass filter** +**Filter banks of 2 filters: a low pass and a high pass** -* :class:`Heat` -* :class:`Expwin` +* :class:`Regular`: design 2 filters with the regular construction +* :class:`Held`: design 2 filters with the Held construction +* :class:`Simoncelli`: design 2 filters with the Simoncelli construction +* :class:`Papadakis`: design 2 filters with the Papadakis construction + +**Low pass filters** + +* :class:`Heat`: design an heat kernel filter +* :class:`Expwin`: design an exponential window filter Moreover, two approximation methods are provided for fast filtering. The computational complexity of filtering with those approximations is linear with From 042355fc5686d2f9f7061fab6c93f88015d9c6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 23 Aug 2017 15:59:11 +0200 Subject: [PATCH 200/392] plotting: default backend at module level --- doc/tutorials/graph_tv.rst | 11 ++++---- doc/tutorials/intro.rst | 13 ++++----- doc/tutorials/wavelet.rst | 29 ++++++++++---------- pygsp/plotting.py | 51 ++++++++++++++++++++---------------- pygsp/tests/test_plotting.py | 14 +++++----- 5 files changed, 63 insertions(+), 55 deletions(-) diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst index aab98039..f4adf7cc 100644 --- a/doc/tutorials/graph_tv.rst +++ b/doc/tutorials/graph_tv.rst @@ -43,7 +43,8 @@ Results and code :context: reset >>> import numpy as np - >>> from pygsp import graphs + >>> from pygsp import graphs, plotting + >>> plotting.BACKEND = 'matplotlib' >>> >>> # Create a random sensor graph >>> G = graphs.Sensor(N=256, distribute=True) @@ -52,7 +53,7 @@ Results and code >>> # Create signal >>> graph_value = np.copysign(np.ones(np.shape(G.U[:, 3])[0]), G.U[:, 3]) >>> - >>> G.plot_signal(graph_value, default_qtg=False) + >>> G.plot_signal(graph_value) This figure shows the original signal on graph. @@ -67,7 +68,7 @@ This figure shows the original signal on graph. >>> sigma = 0.0 >>> depleted_graph_value = M * (graph_value.reshape(graph_value.size, 1) + sigma * np.random.standard_normal((G.N, 1))) >>> - >>> G.plot_signal(depleted_graph_value, show_edges=True, default_qtg=False) + >>> G.plot_signal(depleted_graph_value, show_edges=True) This figure shows the signal on graph after the application of the mask and addition of noise. More than half of the vertices are set to 0. @@ -98,7 +99,7 @@ mask and addition of noise. More than half of the vertices are set to 0. >>> # verbosity='LOW') >>> # prox_tv_reconstructed_graph = ret['sol'] >>> - >>> # G.plot_signal(prox_tv_reconstructed_graph, show_edges=True, default_qtg=False) + >>> # G.plot_signal(prox_tv_reconstructed_graph, show_edges=True) This figure shows the reconstructed signal thanks to the algorithm. @@ -125,6 +126,6 @@ The result is presented as following: >>> # verbosity='LOW') >>> # prox_tik_reconstructed_graph = ret['sol'] >>> - >>> # G.plot_signal(prox_tik_reconstructed_graph, show_edges=True, default_qtg=False) + >>> # G.plot_signal(prox_tik_reconstructed_graph, show_edges=True) This figure shows the reconstructed signal thanks to the algorithm. diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 976bc63b..022aa678 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -9,7 +9,8 @@ To start open a python shell (IPython is recommended here) and import the pygsp. :context: reset >>> import numpy as np - >>> from pygsp import graphs, filters + >>> from pygsp import graphs, filters, plotting + >>> plotting.BACKEND = 'matplotlib' The first step is to create a graph, there's a general class that can be used to generate graph from it's weight matrix. @@ -35,7 +36,7 @@ You can now plot the graph: .. plot:: :context: close-figs - >>> G.plot(default_qtg=False) + >>> G.plot() Looks good isn't it? Now we can start to analyse the graph. The next step to compute Graph Fourier Transform or exact graph filtering is to precompute the Fourier basis of the graph. This operation can be very long as it needs to to fully diagonalize the Laplacian. Happily it is not needed to filter signal on graphs. @@ -50,8 +51,8 @@ Let's plot the second and third eigenvectors, as the first is constant. .. plot:: :context: close-figs - >>> G.plot_signal(G.U[:, 1], vertex_size=50, default_qtg=False) - >>> G.plot_signal(G.U[:, 2], vertex_size=50, default_qtg=False) + >>> G.plot_signal(G.U[:, 1], vertex_size=50) + >>> G.plot_signal(G.U[:, 2], vertex_size=50) Let's discover basic filters operations, filters are usually defined in the spectral domain. @@ -102,8 +103,8 @@ Finally here's the noisy signal and the denoised version right under. .. plot:: :context: close-figs - >>> G.plot_signal(f, vertex_size=50, default_qtg=False) - >>> G.plot_signal(f2, vertex_size=50, default_qtg=False) + >>> G.plot_signal(f, vertex_size=50) + >>> G.plot_signal(f2, vertex_size=50) So here are the basics for the PyGSP toolbox, please check the other tutorials or the reference guide for more. diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index c0fa336f..1909e63e 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -14,7 +14,8 @@ First let's import the toolbox, numpy and load a graph. :context: reset >>> import numpy as np - >>> from pygsp import graphs, filters + >>> from pygsp import graphs, filters, plotting + >>> plotting.BACKEND = 'matplotlib' >>> G = graphs.Bunny() This graph is a nearest-neighbor graph of a pointcloud of the Stanford bunny. It will allow us to get interesting visual results using wavelets. @@ -55,10 +56,10 @@ Let's plot the signal: .. plot:: :context: close-figs - >>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,0], vertex_size=20) + >>> G.plot_signal(Sf[:,1], vertex_size=20) + >>> G.plot_signal(Sf[:,2], vertex_size=20) + >>> G.plot_signal(Sf[:,3], vertex_size=20) Visualizing wavelets atoms -------------------------- @@ -86,7 +87,7 @@ If we want to get a better coverage of the graph spectrum, we could have used th >>> S_vec = Wk.analysis(S) >>> S = S_vec.reshape((S_vec.size//Nf, Nf), order='F') - >>> G.plot_signal(S[:, 0], default_qtg=False) + >>> G.plot_signal(S[:, 0]) We can visualize the filtering by one atom the same way the did for the Heat kernel, by placing a Kronecker delta at one specific vertex. @@ -99,10 +100,10 @@ We can visualize the filtering by one atom the same way the did for the Heat ker ... S[vertex_delta + i * G.N, i] = 1 >>> Sf = Wk.synthesis(S) >>> - >>> G.plot_signal(Sf[:,0], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,1], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,2], vertex_size=20, default_qtg=False) - >>> G.plot_signal(Sf[:,3], vertex_size=20, default_qtg=False) + >>> G.plot_signal(Sf[:,0], vertex_size=20) + >>> G.plot_signal(Sf[:,1], vertex_size=20) + >>> G.plot_signal(Sf[:,2], vertex_size=20) + >>> G.plot_signal(Sf[:,3], vertex_size=20) .. plot:: :context: close-figs @@ -117,7 +118,7 @@ We can visualize the filtering by one atom the same way the did for the Heat ker >>> d = s_map_out[:, :, 0]**2 + s_map_out[:, :, 1]**2 + s_map_out[:, :, 2]**2 >>> d = np.sqrt(d) >>> - >>> G.plot_signal(d[:, 1], vertex_size=20, default_qtg=False) - >>> G.plot_signal(d[:, 2], vertex_size=20, default_qtg=False) - >>> G.plot_signal(d[:, 3], vertex_size=20, default_qtg=False) - >>> G.plot_signal(d[:, 4], vertex_size=20, default_qtg=False) + >>> G.plot_signal(d[:, 1], vertex_size=20) + >>> G.plot_signal(d[:, 2], vertex_size=20) + >>> G.plot_signal(d[:, 3], vertex_size=20) + >>> G.plot_signal(d[:, 4], vertex_size=20) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 3a753f0e..68b373dd 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -3,12 +3,18 @@ r""" The :mod:`pygsp.plotting` module implements functionality to plot PyGSP objects with a `pyqtgraph `_ or `matplotlib -`_ drawing backend: +`_ drawing backend (which can be controlled by the +:data:`BACKEND` constant or individually for each plotting call): * graphs from :mod:`pygsp.graphs` with :func:`plot_graph`, :func:`plot_spectrogram`, and :func:`plot_signal`, * filters from :mod:`pygsp.filters` with :func:`plot_filter`. +.. data:: BACKEND + + Indicates which drawing backend to use if none are provided to the plotting + functions. Should be either 'matplotlib' or 'pyqtgraph'. + """ import numpy as np @@ -34,6 +40,7 @@ qtg_import = False +BACKEND = 'pyqtgraph' _qtg_windows = [] _qtg_widgets = [] _plt_figures = [] @@ -111,22 +118,18 @@ def plot(O, **kwargs): raise TypeError('Unrecognized object, i.e. not a Graph or Filter.') -def plot_graph(G, default_qtg=True, **kwargs): +def plot_graph(G, backend=None, **kwargs): r""" Plot a graph or a list of graphs. - This function should be able to determine the appropriate plot for - the graph. - Parameters ---------- G : Graph Graph to plot. show_edges : boolean Set to False to only draw the vertices (default G.Ne < 10000). - default_qtg: boolean - define the drawing backend to use if both are available. - Default True, i.e. pyqtgraph. + backend: {'matplotlib', 'pyqtgraph'} + Defines the drawing backend to use. Defaults to :data:`BACKEND`. plot_name : string name of the plot savefig : boolean @@ -151,15 +154,17 @@ def plot_graph(G, default_qtg=True, **kwargs): raise AttributeError('Graph has no coordinate set. ' 'Please run G.set_coordinates() first.') if G.coords.shape[1] not in [2, 3]: - raise AttributeError('Coordinates should be in 2 or 3D space.') + raise AttributeError('Coordinates should be in 2D or 3D space.') - if qtg_import and (default_qtg or not plt_import): + if backend is None: + backend = BACKEND + + if backend == 'pyqtgraph' and qtg_import: _qtg_plot_graph(G, **kwargs) - elif plt_import and not (default_qtg and qtg_import): + elif backend == 'matplotlib' and plt_import: _plt_plot_graph(G, **kwargs) else: - raise ImportError('No drawing library installed. Please ' - 'install matplotlib or pyqtgraph.') + raise ValueError('The {} backend is not available.'.format(backend)) def _plt_plot_graph(G, savefig=False, show_edges=None, @@ -392,7 +397,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x_size=10, plot_eigenvalues=None, show_sum=None, savefig=False, show_plot=False, plot_name=None): r""" - Plot a system of graph spectral filters. + Plot a filter bank, i.e. a set of graph filters. Parameters ---------- @@ -428,6 +433,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, >>> plotting.plot_filter(mh) """ + G = filters.G if not isinstance(filters.g, list): @@ -476,7 +482,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, plt.show(False) # non blocking show -def plot_signal(G, signal, default_qtg=True, **kwargs): +def plot_signal(G, signal, backend=None, **kwargs): r""" Plot a signal on top of a graph. @@ -504,9 +510,8 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): (default False). bar_width : int Width of the bar (default 1). - default_qtg: boolean - define the drawing backend to use if both are available. - Default True, i.e. pyqtgraph. + backend: {'matplotlib', 'pyqtgraph'} + Defines the drawing backend to use. Defaults to :data:`BACKEND`. plot_name : string name of the plot savefig : boolean @@ -530,13 +535,15 @@ def plot_signal(G, signal, default_qtg=True, **kwargs): raise AttributeError('Graph has no coordinate set. ' 'Please run G.set_coordinates() first.') - if qtg_import and (default_qtg or not plt_import): + if backend is None: + backend = BACKEND + + if backend == 'pyqtgraph' and qtg_import: _qtg_plot_signal(G, signal, **kwargs) - elif plt_import and not (default_qtg and qtg_import): + elif backend == 'matplotlib' and plt_import: _plt_plot_signal(G, signal, **kwargs) else: - raise ImportError('No drawing library installed. Please ' - 'install matplotlib or pyqtgraph.') + raise ValueError('The {} backend is not available.'.format(backend)) def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index dc52db22..a62758f2 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -79,16 +79,14 @@ def test_plot_graphs(self): if G.is_directed(): self.assertRaises(NotImplementedError, - G.plot, default_qtg=True) + G.plot, backend='pyqtgraph') self.assertRaises(NotImplementedError, - G.plot, default_qtg=False) + G.plot, backend='matplotlib') else: - # Backend: pyqtgraph. - G.plot(default_qtg=True) - G.plot_signal(signal, default_qtg=True) - # Backend: matplotlib. - G.plot(default_qtg=False) - G.plot_signal(signal, default_qtg=False) + G.plot(backend='pyqtgraph') + G.plot(backend='matplotlib') + G.plot_signal(signal, backend='pyqtgraph') + G.plot_signal(signal, backend='matplotlib') plotting.close_all() From 16ba0166e0ddccc4070e49589e3527046315ce31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 23 Aug 2017 16:06:08 +0200 Subject: [PATCH 201/392] doc --- pygsp/plotting.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 68b373dd..4b2a59dd 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -13,7 +13,9 @@ .. data:: BACKEND Indicates which drawing backend to use if none are provided to the plotting - functions. Should be either 'matplotlib' or 'pyqtgraph'. + 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. """ From ffd34d21957aa7ee3c807bf29d1693ef56115259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 14:10:50 +0200 Subject: [PATCH 202/392] localization as a Filter method --- pygsp/filters/__init__.py | 1 + pygsp/filters/filter.py | 54 ++++++++++++++++++++++++++++++++- pygsp/operators/__init__.py | 2 -- pygsp/operators/localization.py | 26 ---------------- pygsp/tests/test_filters.py | 18 +++++++++++ 5 files changed, 72 insertions(+), 29 deletions(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index c0d70a74..6ade5172 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -16,6 +16,7 @@ * :meth:`Filter.compute_frame`: return a matrix operator * :meth:`Filter.estimate_frame_bounds`: estimate lower and upper frame bounds * :meth:`Filter.plot`: plot the filter frequency response +* :meth:`Filter.localize`: localize a kernel at a node (to visualize it) Then, derived classes implement various common graph filters. diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 9e8d4fa7..b0d43421 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -335,6 +335,48 @@ def synthesis(self, c, method='chebyshev', order=30): return s + def localize(self, i, **kwargs): + r""" + Localize the filter kernel at node i. + + That is particularly useful to visualize a filter in the vertex domain. + + A kernel is localized by filtering a Kronecker delta, i.e. + + .. math:: g(L) s = g(L)_i, \text{ where } s_j = \delta_{ij} = + \begin{cases} 0 \text{ if } i \neq j \\ + 1 \text{ if } i = j \end{cases} + + Parameters + ---------- + i : int + Index of the node where to localize the kernel. + kwargs: dict + Parameters to be passed to the :meth:`analysis` method. + + Returns + ------- + s : ndarray + Kernel localized at vertex i. + + Examples + -------- + Visualize heat diffusion on a grid. + + >>> from pygsp import graphs, filters + >>> N = 20 + >>> G = graphs.Grid2d(N) + >>> G.estimate_lmax() + >>> g = filters.Heat(G, 100) + >>> s = g.localize(N//2 * (N+1)) + >>> G.plot_signal(s) + + """ + + s = np.zeros(self.G.N) + s[i] = 1 + return np.sqrt(self.G.N) * self.analysis(s, **kwargs) + def approx(self, m, N): r""" Not implemented yet. @@ -431,7 +473,17 @@ def compute_frame(self, **kwargs): The size of the returned matrix operator :math:`D` is N x MN, where M is the number of filters and N the number of nodes. Multiplying this matrix with a set of signals is equivalent to analyzing them with the - associated filterbank. + associated filterbank. Though computing this matrix is a rather + inefficient way of doing it. + + The frame is defined as follows: + + .. math:: g_i(L) = U g_i(\Lambda) U^*, + + where :math:`g` is the filter kernel, :math:`L` is the graph Laplacian, + :math:`\Lambda` is a diagonal matrix of the Laplacian's eigenvalues, + and :math:`U` is the Fourier basis, i.e. its columns are the + eigenvectors of the Laplacian. Parameters ---------- diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index bfc36095..a1133e85 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -18,7 +18,6 @@ **Localization** -* :func:`localize`: localize a kernel * :func:`modulate`: generalized modulation operator * :func:`translate`: generalized translation operator @@ -49,7 +48,6 @@ 'gft_windowed_normalized', ] _LOCALIZATION = [ - 'localize', 'modulate', 'translate', ] diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py index 9b4ed37f..e31ce03b 100644 --- a/pygsp/operators/localization.py +++ b/pygsp/operators/localization.py @@ -9,32 +9,6 @@ logger = utils.build_logger(__name__) -def localize(g, i): - r""" - Localize a kernel g to the node i. - - Parameters - ---------- - g : Filter - kernel (or filterbank) - i : int - Index of vertex - - Returns - ------- - gt : ndarray - Translated signal - - """ - N = g.G.N - f = np.zeros((N)) - f[i - 1] = 1 - - gt = np.sqrt(N) * g.analysis(f) - - return gt - - def modulate(G, f, k): r""" Modulation the signal f to the frequency k. diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 2a5fcff7..74e64712 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -71,6 +71,24 @@ def _test_methods(self, f): self.assertRaises(NotImplementedError, f.inverse, 0) self.assertRaises(NotImplementedError, f.tighten) + def test_localize(self): + G = graphs.Grid2d(20) + G.compute_fourier_basis() + g = filters.Heat(G, 100) + + # Localize signal at node by filterting Kronecker delta. + NODE = 10 + s1 = g.localize(NODE, method='exact') + + # Should be equal to a row / column of the filtering operator. + gL = G.U.dot(np.diag(g.evaluate(G.e)).dot(G.U.T)) + s2 = np.sqrt(G.N) * gL[NODE, :] + np.testing.assert_allclose(s1, s2) + + # That is actually a row / column of the analysis operator. + F = g.compute_frame(method='exact') + np.testing.assert_allclose(F, gL) + def test_custom_filter(self): def _filter(x): return x / (1. + x) From 998ae4a4842aed423eee77a16132aa1d12c0fcdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 15:15:54 +0200 Subject: [PATCH 203/392] update vec2mat --- pygsp/utils.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 9f37649a..9cb3762b 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -377,23 +377,23 @@ def vec2mat(d, Nf): Parameters ---------- d : ndarray - Data + Coefficients from :func:`pygsp.filters.Filter.analysis`. Nf : int - Number of filters + Number of filters. Returns ------- d : list of ndarray - Data + Reshaped coefficients. """ - if len(np.shape(d)) == 1: - M = np.shape(d)[0] - return np.reshape(d, (M // Nf, Nf), order='F') + if d.ndim == 1: + M = d.shape[0] + return d.reshape((M // Nf, Nf), order='F') - if len(np.shape(d)) == 2: - M, N = np.shape(d) - return np.reshape(d, (M // Nf, Nf, N), order='F') + elif d.ndim == 2: + M, N = d.shape + return d.reshape((M // Nf, Nf, N), order='F') def extract_patches(img, patch_shape=(3, 3)): From c1d4d897861c15816228b764f98f675f890c4798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 15:17:12 +0200 Subject: [PATCH 204/392] doc: show inherited methods --- doc/reference/features.rst | 1 + doc/reference/filters.rst | 1 + doc/reference/graphs.rst | 1 + doc/reference/operators.rst | 1 + doc/reference/optimization.rst | 1 + doc/reference/plotting.rst | 1 + doc/reference/utils.rst | 1 + 7 files changed, 7 insertions(+) diff --git a/doc/reference/features.rst b/doc/reference/features.rst index f93de255..8f08b7b6 100644 --- a/doc/reference/features.rst +++ b/doc/reference/features.rst @@ -5,3 +5,4 @@ Features .. automodule:: pygsp.features :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index d2b7c389..3946dd74 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -5,3 +5,4 @@ Filters .. automodule:: pygsp.filters :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index b9da7102..5b5a0424 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -5,3 +5,4 @@ Graphs .. automodule:: pygsp.graphs :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/operators.rst b/doc/reference/operators.rst index df7dce44..68b90a46 100644 --- a/doc/reference/operators.rst +++ b/doc/reference/operators.rst @@ -5,3 +5,4 @@ Operators .. automodule:: pygsp.operators :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/optimization.rst b/doc/reference/optimization.rst index 2fada89c..8599ead2 100644 --- a/doc/reference/optimization.rst +++ b/doc/reference/optimization.rst @@ -5,3 +5,4 @@ Optimization .. automodule:: pygsp.optimization :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/plotting.rst b/doc/reference/plotting.rst index 442ed6af..435d8882 100644 --- a/doc/reference/plotting.rst +++ b/doc/reference/plotting.rst @@ -5,3 +5,4 @@ Plotting .. automodule:: pygsp.plotting :members: :undoc-members: + :inherited-members: diff --git a/doc/reference/utils.rst b/doc/reference/utils.rst index 5343b61d..b9d3e1f1 100644 --- a/doc/reference/utils.rst +++ b/doc/reference/utils.rst @@ -5,3 +5,4 @@ Utilities .. automodule:: pygsp.utils :members: :undoc-members: + :inherited-members: From 8b74f74f7b3caf24f30d04fac01b95c9eb50f973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 15:23:54 +0200 Subject: [PATCH 205/392] Fourier transforms as Graph methods --- pygsp/filters/filter.py | 25 ++- pygsp/graphs/__init__.py | 8 + pygsp/graphs/fourier.py | 246 +++++++++++++++++++++++++++ pygsp/graphs/graph.py | 3 +- pygsp/operators/__init__.py | 18 +- pygsp/operators/localization.py | 5 +- pygsp/operators/transforms.py | 289 -------------------------------- pygsp/tests/test_graphs.py | 22 +++ pygsp/tests/test_operators.py | 17 -- 9 files changed, 293 insertions(+), 340 deletions(-) create mode 100644 pygsp/graphs/fourier.py delete mode 100644 pygsp/operators/transforms.py diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index b0d43421..ad8b3a82 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -7,7 +7,6 @@ from pygsp import utils # prevent circular import in Python < 3.5 from . import approximations -from ..operators.transforms import gft, igft _logger = utils.build_logger(__name__) @@ -150,21 +149,21 @@ def analysis(self, s, method='chebyshev', order=30): if self.Nf == 1: if is2d: - fs = np.tile(fie, (Ns, 1)).T * gft(self.G, s) - return igft(self.G, fs) + fs = np.tile(fie, (Ns, 1)).T * self.G.gft(s) + return self.G.igft(fs) else: - fs = fie * gft(self.G, s) - return igft(self.G, fs) + fs = fie * self.G.gft(s) + return self.G.igft(fs) else: tmpN = np.arange(N, dtype=int) for i in range(self.Nf): if is2d: - fs = gft(self.G, s) + fs = self.G.gft(s) fs *= np.tile(fie[i], (Ns, 1)).T - c[tmpN + N * i] = igft(self.G, fs) + c[tmpN + N * i] = self.G.igft(fs) else: - fs = fie[i] * gft(self.G, s) - c[tmpN + N * i] = igft(self.G, fs) + fs = fie[i] * self.G.gft(s) + c[tmpN + N * i] = self.G.igft(fs) else: raise ValueError('Unknown method: {}'.format(method)) @@ -303,13 +302,13 @@ def synthesis(self, c, method='chebyshev', order=30): tmpN = np.arange(N, dtype=int) if self.Nf == 1: - fc = np.tile(fie, (Nv, 1)).T * gft(self.G, c[tmpN]) - s += igft(np.conjugate(self.G.U), fc) + fc = np.tile(fie, (Nv, 1)).T * self.G.gft(c[tmpN]) + s += np.dot(np.conjugate(self.G.U), fc) else: for i in range(self.Nf): - fc = gft(self.G, c[N * i + tmpN]) + fc = self.G.gft(c[N * i + tmpN]) fc *= np.tile(fie[:][i], (Nv, 1)).T - s += igft(np.conjugate(self.G.U), fc) + s += np.dot(np.conjugate(self.G.U), fc) elif method == 'chebyshev': cheb_coeffs = approximations.compute_cheby_coeff(self, m=order, diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 23bf4d0f..3a0d13b2 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -8,6 +8,14 @@ The :class:`Graph` base class allows to construct a graph object from any adjacency matrix and provides a common interface to that object. +**Fourier basis and transforms** (frequency and vertex-frequency) + +* :meth:`Graph.gft`: graph Fourier transform (GFT) +* :meth:`Graph.igft`: inverse graph Fourier transform +* :meth:`Graph.gft_windowed`: windowed GFT +* :meth:`Graph.gft_windowed_gabor`: Gabor windowed GFT +* :meth:`Graph.gft_windowed_normalized`: normalized windowed GFT + Derived classes implement various graph models. * :class:`Airfoil` diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py new file mode 100644 index 00000000..1e138f4c --- /dev/null +++ b/pygsp/graphs/fourier.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- + +import numpy as np + +from pygsp import utils + + +logger = utils.build_logger(__name__) + + +class GraphFourier(object): + + def gft(self, s): + r""" + Compute graph Fourier transform. + + The graph Fourier transform of a signal :math:`s` is defined as + + .. math:: \hat{s} = U^* s, + + where :math:`U` is the Fourier basis attr:`U` and :math:`U^*` denotes + the conjugate transpose or Hermitian transpose of :math:`U`. + + Parameters + ---------- + s : ndarray + Graph signal in the vertex domain. + + Returns + ------- + s_hat : ndarray + Representation of s in the Fourier domain. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s = np.random.normal(size=G.N) + >>> s_hat = G.gft(s) + >>> s_star = G.igft(s_hat) + >>> np.linalg.norm(s - s_star) < 1e-10 + True + + """ + return np.dot(np.conjugate(self.U.T), s) # True Hermitian here. + + def igft(self, s_hat): + r""" + Compute inverse graph Fourier transform. + + The inverse graph Fourier transform of a Fourier domain signal + :math:`\hat{s}` is defined as + + .. math:: s = U \hat{s}, + + where :math:`U` is the Fourier basis :attr:`U`. + + Parameters + ---------- + s_hat : ndarray + Graph signal in the Fourier domain. + + Returns + ------- + s : ndarray + Representation of s_hat in the vertex domain. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s_hat = np.random.normal(size=G.N) + >>> s = G.igft(s_hat) + >>> s_hat_star = G.gft(s) + >>> np.linalg.norm(s_hat - s_hat_star) < 1e-10 + True + + """ + return np.dot(self.U, s_hat) + + def gft_windowed_gabor(self, f, k): + r""" + Gabor windowed graph Fourier transform. + + Parameters + ---------- + f : ndarray + Graph signal in the vertex domain. + k : function + Gabor kernel. See :class:`pygsp.filters.Gabor`. + + Returns + ------- + C : ndarray + Coefficients. + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs, operators + >>> G = graphs.Logo() + >>> s = np.random.normal(size=G.N) + >>> C = G.gft_windowed_gabor(s, lambda x: x/(1.-x)) + >>> C.shape == (G.N, G.N) + True + + """ + + from pygsp import filters + + g = filters.Gabor(self, k) + + C = g.analysis(f) + C = utils.vec2mat(C, self.N).T + + return C + + def gft_windowed(self, g, f, lowmemory=True): + r""" + Windowed graph Fourier transform. + + Parameters + ---------- + g : ndarray or Filter + Window (graph signal or kernel). + f : ndarray + Graph signal in the vertex domain. + lowmemory : bool + Use less memory (default=True). + + Returns + ------- + C : ndarray + Coefficients. + + """ + + raise NotImplementedError('Current implementation is not working.') + + N = self.N + Nf = np.shape(f)[1] + U = self.U + + if isinstance(g, list): + g = self.igft(g[0](self.e)) + elif hasattr(g, '__call__'): + g = self.igft(g(self.e)) + + if not lowmemory: + # Compute the Frame into a big matrix + Frame = self._frame_matrix(g, normalize=False) + + C = np.dot(Frame.T, f) + C = np.reshape(C, (N, N, Nf), order='F') + + else: + # Compute the translate of g + # TODO: use operators.translate() + ghat = np.dot(U.T, g) + Ftrans = np.sqrt(N) * np.dot(U, (np.kron(np.ones((N)), ghat)*U.T)) + C = np.empty((N, N)) + + for j in range(Nf): + for i in range(N): + C[:, i, j] = (np.kron(np.ones((N)), 1./U[:, 0])*U*np.dot(np.kron(np.ones((N)), Ftrans[:, i])).T, f[:, j]) + + return C + + def gft_windowed_normalized(self, g, f, lowmemory=True): + r""" + Normalized windowed graph Fourier transform. + + Parameters + ---------- + g : ndarray + Window. + f : ndarray + Graph signal in the vertex domain. + lowmemory : bool + Use less memory. (default = True) + + Returns + ------- + C : ndarray + Coefficients. + + """ + + raise NotImplementedError('Current implementation is not working.') + + N = self.N + U = self.U + + if lowmemory: + # Compute the Frame into a big matrix + Frame = self._frame_matrix(g, normalize=True) + C = np.dot(Frame.T, f) + C = np.reshape(C, (N, N), order='F') + + else: + # Compute the translate of g + # TODO: use operators.translate() + ghat = np.dot(U.T, g) + Ftrans = np.sqrt(N)*np.dot(U, (np.kron(np.ones((1, N)), ghat)*U.T)) + C = np.empty((N, N)) + + for i in range(N): + atoms = np.kron(np.ones((N)), 1./U[:, 0])*U*np.kron(np.ones((N)), Ftrans[:, i]).T + + # normalization + atoms /= np.kron((np.ones((N))), np.sqrt(np.sum(np.abs(atoms), + axis=0))) + C[:, i] = np.dot(atoms, f) + + return C + + def _frame_matrix(self, g, normalize=False): + r""" + Create the GWFT frame. + + Parameters + ---------- + g : window + + Returns + ------- + F : ndarray + Frame + """ + + N = self.N + U = self.U + + if self.N > 256: + logger.warning("It will create a big matrix. You can use other methods.") + + ghat = np.dot(U.T, g) + Ftrans = np.sqrt(N)*np.dot(U, (np.kron(np.ones(N), ghat)*U.T)) + F = utils.repmatline(Ftrans, 1, N)*np.kron(np.ones((N)), np.kron(np.ones((N)), 1./U[:, 0])) + + if normalize: + F /= np.kron((np.ones(N), np.sqrt(np.sum(np.power(np.abs(F), 2), axis=0)))) + + return F diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index fdd827ad..cd454c04 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -7,9 +7,10 @@ from scipy.linalg import svd from pygsp import utils +from . import fourier # prevent circular import in Python < 3.5 -class Graph(object): +class Graph(fourier.GraphFourier): r""" The base graph class. diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index a1133e85..919eb33d 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -8,14 +8,6 @@ * :func:`grad`: compute the gradient of a graph signal * :func:`div`: compute the divergence of a graph signal -**Transforms** (frequency and vertex-frequency) - -* :func:`gft`: graph Fourier transform (GFT) -* :func:`igft`: inverse graph Fourier transform -* :func:`gft_windowed`: windowed GFT -* :func:`gft_windowed_gabor`: Gabor windowed GFT -* :func:`gft_windowed_normalized`: normalized windowed GFT - **Localization** * :func:`modulate`: generalized modulation operator @@ -40,13 +32,6 @@ 'grad', 'div', ] -_TRANSFORMS = [ - 'gft', - 'igft', - 'gft_windowed', - 'gft_windowed_gabor', - 'gft_windowed_normalized', -] _LOCALIZATION = [ 'modulate', 'translate', @@ -62,9 +47,8 @@ 'graph_sparsify', ] -__all__ = _DIFFERENCE + _TRANSFORMS + _LOCALIZATION + _REDUCTION +__all__ = _DIFFERENCE + _LOCALIZATION + _REDUCTION _utils.import_functions(_DIFFERENCE, 'operators.difference', 'operators') -_utils.import_functions(_TRANSFORMS, 'operators.transforms', 'operators') _utils.import_functions(_LOCALIZATION, 'operators.localization', 'operators') _utils.import_functions(_REDUCTION, 'operators.reduction', 'operators') diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py index e31ce03b..bafddfbb 100644 --- a/pygsp/operators/localization.py +++ b/pygsp/operators/localization.py @@ -3,7 +3,6 @@ import numpy as np from pygsp import utils -from . import transforms # prevent circular import in Python < 3.5 logger = utils.build_logger(__name__) @@ -54,9 +53,9 @@ def translate(G, f, i): raise NotImplementedError('Current implementation is not working.') - fhat = transforms.gft(G, f) + fhat = G.gft(f) nt = np.shape(f)[1] - ft = np.sqrt(G.N) * transforms.igft(G, fhat, np.kron(np.ones((1, nt)), G.U[i])) + ft = np.sqrt(G.N) * G.igft(fhat, np.kron(np.ones((1, nt)), G.U[i])) return ft diff --git a/pygsp/operators/transforms.py b/pygsp/operators/transforms.py deleted file mode 100644 index f67df034..00000000 --- a/pygsp/operators/transforms.py +++ /dev/null @@ -1,289 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy as np - -from pygsp import utils - - -logger = utils.build_logger(__name__) - - -def gft(G, s): - r""" - Compute graph Fourier transform. - - The graph Fourier transform of a signal :math:`s` is defined as - - .. math:: \hat{s} = U^* s, - - where :math:`U` is the Fourier basis :py:attr:`pygsp.graphs.Graph.U` and - :math:`U^*` denotes the conjugate transpose or Hermitian transpose of - :math:`U`. - - Parameters - ---------- - G : Graph or Fourier basis - s : ndarray - Graph signal in the vertex domain. - - Returns - ------- - s_hat : ndarray - Representation of s in the Fourier domain. - - Examples - -------- - >>> import numpy as np - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> s = np.random.normal(size=G.N) - >>> s_hat = operators.gft(G, s) - >>> s_star = operators.igft(G, s_hat) - >>> np.linalg.norm(s - s_star) < 1e-10 - True - - """ - - try: - U = G.U - except AttributeError: - U = G - - return np.dot(np.conjugate(U.T), s) # True Hermitian here. - - -def igft(G, s_hat): - r""" - Compute inverse graph Fourier transform. - - The inverse graph Fourier transform of a Fourier domain signal - :math:`\hat{s}` is defined as - - .. math:: s = U \hat{s}, - - where :math:`U` is the Fourier basis :py:attr:`pygsp.graphs.Graph.U`. - - Parameters - ---------- - G : Graph or Fourier basis - s_hat : ndarray - Graph signal in the Fourier domain. - - Returns - ------- - s : ndarray - Representation of s_hat in the vertex domain. - - Examples - -------- - >>> import numpy as np - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> s_hat = np.random.normal(size=G.N) - >>> s = operators.igft(G, s_hat) - >>> s_hat_star = operators.gft(G, s) - >>> np.linalg.norm(s_hat - s_hat_star) < 1e-10 - True - - """ - - try: - U = G.U - except AttributeError: - U = G - - return np.dot(U, s_hat) - - -def gft_windowed(G, g, f, lowmemory=True): - r""" - Windowed graph Fourier transform. - - Parameters - ---------- - G : Graph - g : ndarray or Filter - Window (graph signal or kernel). - f : ndarray - Graph signal in the vertex domain. - lowmemory : bool - Use less memory (default=True). - - Returns - ------- - C : ndarray - Coefficients. - - """ - - raise NotImplementedError('Current implementation is not working.') - - Nf = np.shape(f)[1] - - if isinstance(g, list): - g = igft(G, g[0](G.e)) - elif hasattr(g, '__call__'): - g = igft(G, g(G.e)) - - if not lowmemory: - # Compute the Frame into a big matrix - Frame = _gwft_frame_matrix(G, g) - - C = np.dot(Frame.T, f) - C = np.reshape(C, (G.N, G.N, Nf), order='F') - - else: - # Compute the translate of g - # TODO: use operators.translate() - ghat = np.dot(G.U.T, g) - Ftrans = np.sqrt(G.N) * np.dot(G.U, (np.kron(np.ones((G.N)), ghat)*G.U.T)) - C = np.empty((G.N, G.N)) - - for j in range(Nf): - for i in range(G.N): - C[:, i, j] = (np.kron(np.ones((G.N)), 1./G.U[:, 0])*G.U*np.dot(np.kron(np.ones((G.N)), Ftrans[:, i])).T, f[:, j]) - - return C - - -def gft_windowed_gabor(G, f, k): - r""" - Gabor windowed graph Fourier transform. - - Parameters - ---------- - G : Graph - f : ndarray - Graph signal in the vertex domain. - k : function - Gabor kernel. See :class:`pygsp.filters.Gabor`. - - Returns - ------- - C : ndarray - Coefficients. - - Examples - -------- - >>> import numpy as np - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> s = np.random.normal(size=G.N) - >>> C = operators.gft_windowed_gabor(G, s, lambda x: x/(1.-x)) - >>> C.shape == (G.N, G.N) - True - - """ - - from pygsp import filters - - g = filters.Gabor(G, k) - - C = g.analysis(f) - C = utils.vec2mat(C, G.N).T - - return C - - -def _gwft_frame_matrix(G, g): - r""" - Create the GWFT frame. - - Parameters - ---------- - G : Graph - g : window - - Returns - ------- - F : ndarray - Frame - """ - - if G.N > 256: - logger.warning("It will create a big matrix. You can use other methods.") - - ghat = np.dot(G.U.T, g) - Ftrans = np.sqrt(G.N)*np.dot(G.U, (np.kron(np.ones((1, G.N)), ghat)*G.U.T)) - F = utils.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) - - return F - - -def gft_windowed_normalized(G, g, f, lowmemory=True): - r""" - Normalized windowed graph Fourier transform. - - Parameters - ---------- - G : Graph - g : ndarray - Window. - f : ndarray - Graph signal in the vertex domain. - lowmemory : bool - Use less memory. (default = True) - - Returns - ------- - C : ndarray - Coefficients. - - """ - - raise NotImplementedError('Current implementation is not working.') - - if lowmemory: - # Compute the Frame into a big matrix - Frame = _ngwft_frame_matrix(G, g) - C = np.dot(Frame.T, f) - C = np.reshape(C, (G.N, G.N), order='F') - - else: - # Compute the translate of g - # TODO: use operators.translate() - ghat = np.dot(G.U.T, g) - Ftrans = np.sqrt(G.N)*np.dot(G.U, (np.kron(np.ones((1, G.N)), ghat)*G.U.T)) - C = np.empty((G.N, G.N)) - - for i in range(G.N): - atoms = np.kron(np.ones((G.N)), 1./G.U[:, 0])*G.U*np.kron(np.ones((G.N)), Ftrans[:, i]).T - - # normalization - atoms /= np.kron((np.ones((G.N))), np.sqrt(np.sum(np.abs(atoms), - axis=0))) - C[:, i] = np.dot(atoms, f) - - return C - - -def _ngwft_frame_matrix(G, g): - r""" - Create the NGWFT frame. - - Parameters - ---------- - G : Graph - g : ndarray - Window - - Returns - ------- - F : ndarray - Frame - - """ - - if G.N > 256: - logger.warning('It will create a big matrix, you can use other methods.') - - ghat = np.dot(G.U.T, g) - Ftrans = np.sqrt(g.N)*np.dot(G.U, (np.kron(np.ones((G.N)), ghat)*G.U.T)) - - F = utils.repmatline(Ftrans, 1, G.N)*np.kron(np.ones((G.N)), np.kron(np.ones((G.N)), 1./G.U[:, 0])) - - # Normalization - F /= np.kron((np.ones((G.N)), np.sqrt(np.sum(np.power(np.abs(F), 2), - axiis=0)))) - - return F diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ac6099c2..27360b69 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -17,6 +17,12 @@ class TestCase(unittest.TestCase): @classmethod def setUpClass(cls): + cls._G = graphs.Logo() + cls._G.compute_fourier_basis() + + rs = np.random.RandomState(42) + cls._signal = rs.uniform(size=cls._G.N) + cls._img = img_as_float(data.camera()[::16, ::16]) @classmethod @@ -50,6 +56,22 @@ def test_laplacian(self): self.assertRaises(NotImplementedError, G.compute_laplacian, lap_type='normalized') + def test_fourier_transform(self): + f_hat = self._G.gft(self._signal) + f_star = self._G.igft(f_hat) + np.testing.assert_allclose(self._signal, f_star) + + def test_gft_windowed_gabor(self): + self._G.gft_windowed_gabor(self._signal, lambda x: x/(1.-x)) + + def test_gft_windowed(self): + self.assertRaises(NotImplementedError, self._G.gft_windowed, + None, self._signal) + + def test_gft_windowed_normalized(self): + self.assertRaises(NotImplementedError, self._G.gft_windowed_normalized, + None, self._signal) + def test_edge_list(self): G = graphs.StochasticBlockModel(undirected=True) v_in, v_out, weights = G.get_edge_list() diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py index e6f18cdd..e45c67de 100644 --- a/pygsp/tests/test_operators.py +++ b/pygsp/tests/test_operators.py @@ -33,26 +33,9 @@ def test_difference(self): Ls = operators.div(G, s_grad) np.testing.assert_allclose(Ls, G.L.dot(self.signal)) - def test_fourier_transform(self): - f_hat = operators.gft(self.G, self.signal) - f_star = operators.igft(self.G, f_hat) - np.testing.assert_allclose(self.signal, f_star) - def test_translate(self): self.assertRaises(NotImplementedError, operators.translate, self.G, self.signal, 42) - def test_gft_windowed(self): - self.assertRaises(NotImplementedError, operators.gft_windowed, - self.G, None, self.signal) - - def test_gft_windowed_gabor(self): - operators.gft_windowed_gabor(self.G, self.signal, lambda x: x/(1.-x)) - - def test_gft_windowed_normalized(self): - self.assertRaises(NotImplementedError, - operators.gft_windowed_normalized, - self.G, None, self.signal) - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From f3d4909fea0b13d2de001b504aca97c8fd07ef4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 15:36:14 +0200 Subject: [PATCH 206/392] move Fourier stuff in fourier.py --- pygsp/graphs/fourier.py | 108 ++++++++++++++++++++++++++++++++++++++++ pygsp/graphs/graph.py | 106 --------------------------------------- 2 files changed, 108 insertions(+), 106 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 1e138f4c..a85066f3 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import numpy as np +import scipy.linalg from pygsp import utils @@ -10,6 +11,113 @@ class GraphFourier(object): + def _check_fourier_properties(self, name, desc): + if not hasattr(self, '_' + name): + self.logger.warning('The {} G.{} is not available, we need to ' + 'compute the Fourier basis. Explicitly call ' + 'G.compute_fourier_basis() once beforehand ' + 'to suppress the warning.'.format(desc, name)) + self.compute_fourier_basis() + return getattr(self, '_' + name) + + @property + def U(self): + r""" + Fourier basis, i.e. the eigenvectors of the Laplacian. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('U', 'Fourier basis') + + @property + def e(self): + r""" + Graph frequencies, i.e. the eigenvalues of the Laplacian. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('e', 'eigenvalues vector') + + @property + def mu(self): + r""" + Coherence of the Fourier basis. + Is computed by :func:`compute_fourier_basis`. + """ + return self._check_fourier_properties('mu', 'Fourier basis coherence') + + def compute_fourier_basis(self, smallest_first=True, recompute=False, + **kwargs): + r""" + Compute the Fourier basis of the graph. + + The result is cached and accessible by the :py:attr:`U`, + :py:attr:`e`, :py:attr:`lmax`, and :py:attr:`mu` properties. + + Parameters + ---------- + smallest_first: bool + Define the order of the eigenvalues. + Default is smallest first (True). + recompute: bool + Force to recompute the Fourier basis if already existing. + + Notes + ----- + 'G.compute_fourier_basis()' computes a full eigendecomposition of + the graph Laplacian :math:`L` such that: + + .. math:: L = U \Lambda U^*, + + where :math:`\Lambda` is a diagonal matrix of eigenvalues and the + columns of :math:`U` are the eigenvectors. + + *G.e* is a vector of length *G.N* containing the Laplacian + eigenvalues. The largest eigenvalue is stored in *G.lmax*. + The eigenvectors are stored as column vectors of *G.U* in the same + order that the eigenvalues. Finally, the coherence of the + Fourier basis is found in *G.mu*. + + References + ---------- + See :cite:`chung1997spectral` + + Examples + -------- + >>> from pygsp import graphs + >>> G = graphs.Torus() + >>> G.compute_fourier_basis() + >>> G.U.shape + (256, 256) + >>> G.e.shape + (256,) + >>> G.lmax == G.e[-1] + True + >>> G.mu < 1 + True + + """ + + if hasattr(self, '_e') and hasattr(self, '_U') and not recompute: + return + + if self.N > 3000: + self.logger.warning("Performing full eigendecomposition of a " + "large matrix may take some time.") + + if not hasattr(self, 'L'): + raise AttributeError("Graph Laplacian is missing.") + + # TODO: np.linalg.{svd,eigh}, sparse.linalg.{svds,eigsh} + eigenvectors, eigenvalues, _ = scipy.linalg.svd(self.L.todense()) + + inds = np.argsort(eigenvalues) + if not smallest_first: + inds = inds[::-1] + + self._e = np.sort(eigenvalues) + self._lmax = np.max(self._e) + self._U = eigenvectors[:, inds] + self._mu = np.max(np.abs(self._U)) + def gft(self, s): r""" Compute graph Fourier transform. diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index cd454c04..4735e2c3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -4,7 +4,6 @@ import numpy as np from scipy import sparse -from scipy.linalg import svd from pygsp import utils from . import fourier # prevent circular import in Python < 3.5 @@ -561,111 +560,6 @@ def extract_components(self): return graphs - def _check_fourier_properties(self, name, desc): - if not hasattr(self, '_' + name): - self.logger.warning('The {} G.{} is not available, we need to ' - 'compute the Fourier basis. Explicitly call ' - 'G.compute_fourier_basis() once beforehand ' - 'to suppress the warning.'.format(desc, name)) - self.compute_fourier_basis() - return getattr(self, '_' + name) - - @property - def U(self): - r""" - Fourier basis, i.e. the eigenvectors of the Laplacian. - Is computed by :func:`compute_fourier_basis`. - """ - return self._check_fourier_properties('U', 'Fourier basis') - - @property - def e(self): - r""" - Graph frequencies, i.e. the eigenvalues of the Laplacian. - Is computed by :func:`compute_fourier_basis`. - """ - return self._check_fourier_properties('e', 'eigenvalues vector') - - @property - def mu(self): - r""" - Coherence of the Fourier basis. - Is computed by :func:`compute_fourier_basis`. - """ - return self._check_fourier_properties('mu', 'Fourier basis coherence') - - def compute_fourier_basis(self, smallest_first=True, recompute=False, - **kwargs): - r""" - Compute the Fourier basis of the graph. - - The result is cached and accessible by the :py:attr:`U`, - :py:attr:`e`, :py:attr:`lmax`, and :py:attr:`mu` properties. - - Parameters - ---------- - smallest_first: bool - Define the order of the eigenvalues. - Default is smallest first (True). - recompute: bool - Force to recompute the Fourier basis if already existing. - - Notes - ----- - 'G.compute_fourier_basis()' computes a full eigendecomposition of - the graph Laplacian :math:`L` such that: - - .. math:: L = U \Lambda U^*, - - where :math:`\Lambda` is a diagonal matrix of eigenvalues and the - columns of :math:`U` are the eigenvectors. - - *G.e* is a vector of length *G.N* containing the Laplacian - eigenvalues. The largest eigenvalue is stored in *G.lmax*. - The eigenvectors are stored as column vectors of *G.U* in the same - order that the eigenvalues. Finally, the coherence of the - Fourier basis is found in *G.mu*. - - References - ---------- - See :cite:`chung1997spectral` - - Examples - -------- - >>> from pygsp import graphs - >>> G = graphs.Torus() - >>> G.compute_fourier_basis() - >>> G.U.shape - (256, 256) - >>> G.e.shape - (256,) - >>> G.lmax == G.e[-1] - True - >>> G.mu < 1 - True - - """ - if hasattr(self, '_e') and hasattr(self, '_U') and not recompute: - return - - if self.N > 3000: - self.logger.warning("Performing full eigendecomposition of a " - "large matrix may take some time.") - - if not hasattr(self, 'L'): - raise AttributeError("Graph Laplacian is missing.") - - eigenvectors, eigenvalues, _ = svd(self.L.todense()) - - inds = np.argsort(eigenvalues) - if not smallest_first: - inds = inds[::-1] - - self._e = np.sort(eigenvalues) - self._lmax = np.max(self._e) - self._U = eigenvectors[:, inds] - self._mu = np.max(np.abs(self._U)) - def compute_laplacian(self, lap_type='combinatorial'): r""" Compute a graph Laplacian. From 1d5eb56433ba23a5fc06d61bb4725178bf8955c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 15:53:19 +0200 Subject: [PATCH 207/392] translate and modulate as Graph methods --- pygsp/graphs/__init__.py | 5 +++ pygsp/graphs/fourier.py | 27 +++++++++++++++ pygsp/graphs/graph.py | 24 +++++++++++++ pygsp/operators/__init__.py | 12 +------ pygsp/operators/localization.py | 61 --------------------------------- pygsp/tests/test_graphs.py | 9 +++++ pygsp/tests/test_operators.py | 4 --- 7 files changed, 66 insertions(+), 76 deletions(-) delete mode 100644 pygsp/operators/localization.py diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 3a0d13b2..033c2ed2 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -8,6 +8,11 @@ The :class:`Graph` base class allows to construct a graph object from any adjacency matrix and provides a common interface to that object. +**Localization** + +* :meth:`Graph.modulate`: generalized modulation operator +* :meth:`Graph.translate`: generalized translation operator + **Fourier basis and transforms** (frequency and vertex-frequency) * :meth:`Graph.gft`: graph Fourier transform (GFT) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index a85066f3..5f074e98 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -188,6 +188,33 @@ def igft(self, s_hat): """ return np.dot(self.U, s_hat) + def translate(self, f, i): + r""" + Translate the signal f to the node i. + + Parameters + ---------- + f : ndarray + Signal + i : int + Indices of vertex + + Returns + ------- + ft : translate signal + + """ + + raise NotImplementedError('Current implementation is not working.') + + fhat = self.gft(f) + nt = np.shape(f)[1] + + ft = self.igft(fhat, np.kron(np.ones((1, nt)), self.U[i])) + ft *= np.sqrt(self.N) + + return ft + def gft_windowed_gabor(self, f, k): r""" Gabor windowed graph Fourier transform. diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 4735e2c3..20c3b380 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -789,6 +789,30 @@ def get_edge_list(self): return v_in, v_out, weights + def modulate(self, f, k): + r""" + Modulation the signal f to the frequency k. + + Parameters + ---------- + f : ndarray + Signal (column) + k : int + Index of frequencies + + Returns + ------- + fm : ndarray + Modulated signal + + """ + + nt = np.shape(f)[1] + fm = np.kron(np.ones((1, nt)), self.U[:, k]) + fm *= np.kron(np.ones((nt, 1)), f) + fm *= np.sqrt(self.N) + return fm + def plot(self, **kwargs): r""" Plot the graph. diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index 919eb33d..d66ccf45 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -8,11 +8,6 @@ * :func:`grad`: compute the gradient of a graph signal * :func:`div`: compute the divergence of a graph signal -**Localization** - -* :func:`modulate`: generalized modulation operator -* :func:`translate`: generalized translation operator - **Reduction** Functionalities for the reduction of graphs' vertex set while keeping the graph structure. * :func:`tree_multiresolution`: compute a multiresolution of trees @@ -32,10 +27,6 @@ 'grad', 'div', ] -_LOCALIZATION = [ - 'modulate', - 'translate', -] _REDUCTION = [ 'tree_multiresolution', 'graph_multiresolution', @@ -47,8 +38,7 @@ 'graph_sparsify', ] -__all__ = _DIFFERENCE + _LOCALIZATION + _REDUCTION +__all__ = _DIFFERENCE + _REDUCTION _utils.import_functions(_DIFFERENCE, 'operators.difference', 'operators') -_utils.import_functions(_LOCALIZATION, 'operators.localization', 'operators') _utils.import_functions(_REDUCTION, 'operators.reduction', 'operators') diff --git a/pygsp/operators/localization.py b/pygsp/operators/localization.py deleted file mode 100644 index bafddfbb..00000000 --- a/pygsp/operators/localization.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- - -import numpy as np - -from pygsp import utils - - -logger = utils.build_logger(__name__) - - -def modulate(G, f, k): - r""" - Modulation the signal f to the frequency k. - - Parameters - ---------- - G : Graph - f : ndarray - Signal (column) - k : int - Index of frequencies - - Returns - ------- - fm : ndarray - Modulated signal - - """ - nt = np.shape(f)[1] - fm = np.sqrt(G.N) * np.kron(np.ones((nt, 1)), f) * \ - np.kron(np.ones((1, nt)), G.U[:, k]) - - return fm - - -def translate(G, f, i): - r""" - Translate the signal f to the node i. - - Parameters - ---------- - G : Graph - f : ndarray - Signal - i : int - Indices of vertex - - Returns - ------- - ft : translate signal - - """ - - raise NotImplementedError('Current implementation is not working.') - - fhat = G.gft(f) - nt = np.shape(f)[1] - - ft = np.sqrt(G.N) * G.igft(fhat, np.kron(np.ones((1, nt)), G.U[i])) - - return ft diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 27360b69..ffbd1c9a 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -72,6 +72,15 @@ def test_gft_windowed_normalized(self): self.assertRaises(NotImplementedError, self._G.gft_windowed_normalized, None, self._signal) + def test_translate(self): + self.assertRaises(NotImplementedError, self._G.translate, + self._signal, 42) + + def test_modulate(self): + # FIXME: don't work + # self._G.modulate(self._signal, 3) + pass + def test_edge_list(self): G = graphs.StochasticBlockModel(undirected=True) v_in, v_out, weights = G.get_edge_list() diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py index e45c67de..aed7d3de 100644 --- a/pygsp/tests/test_operators.py +++ b/pygsp/tests/test_operators.py @@ -33,9 +33,5 @@ def test_difference(self): Ls = operators.div(G, s_grad) np.testing.assert_allclose(Ls, G.L.dot(self.signal)) - def test_translate(self): - self.assertRaises(NotImplementedError, operators.translate, - self.G, self.signal, 42) - suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 53d8cffce5ddeebfdeac01935bc421930b00166f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 16:27:15 +0200 Subject: [PATCH 208/392] div and grad as graph methods --- pygsp/graphs/__init__.py | 5 ++ pygsp/graphs/difference.py | 94 +++++++++++++++++++++++++++++++++++ pygsp/graphs/graph.py | 7 ++- pygsp/operators/__init__.py | 12 +---- pygsp/operators/difference.py | 87 -------------------------------- pygsp/optimization.py | 8 +-- pygsp/tests/test_all.py | 3 +- pygsp/tests/test_graphs.py | 7 +++ pygsp/tests/test_operators.py | 37 -------------- 9 files changed, 115 insertions(+), 145 deletions(-) create mode 100644 pygsp/graphs/difference.py delete mode 100644 pygsp/operators/difference.py delete mode 100644 pygsp/tests/test_operators.py diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 033c2ed2..4eb47867 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -8,6 +8,11 @@ The :class:`Graph` base class allows to construct a graph object from any adjacency matrix and provides a common interface to that object. +**Differential operators** + +* :meth:`Graph.grad`: compute the gradient of a graph signal +* :meth:`Graph.div`: compute the divergence of a graph signal + **Localization** * :meth:`Graph.modulate`: generalized modulation operator diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py new file mode 100644 index 00000000..b0538fa3 --- /dev/null +++ b/pygsp/graphs/difference.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +from pygsp import utils + + +logger = utils.build_logger(__name__) + + +class GraphDifference(object): + + def grad(self, s): + r""" + Compute the graph gradient of a signal. + + The gradient of a signal :math:`s` is defined as + + .. math:: y = D s, + + where :math:`D` is the differential operator :attr:`D`. + + Parameters + ---------- + s : ndarray + Signal of length G.N living on the nodes. + + Returns + ------- + s_grad : ndarray + Gradient signal of length G.Ne/2 living on the edges (non-directed + graph). + + See also + -------- + compute_differential_operator + div : compute the divergence + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> s = np.random.normal(size=G.N) + >>> s_grad = G.grad(s) + >>> s_div = G.div(s_grad) + >>> np.linalg.norm(s_div - G.L.dot(s)) < 1e-10 + True + + """ + if self.N != s.shape[0]: + raise ValueError('Signal length should be the number of nodes.') + return self.D.dot(s) + + def div(self, s): + r""" + Compute the graph divergence of a signal. + + The divergence of a signal :math:`s` is defined as + + .. math:: y = D^T s, + + where :math:`D` is the differential operator :attr:`D`. + + Parameters + ---------- + s : ndarray + Signal of length G.Ne/2 living on the edges (non-directed graph). + + Returns + ------- + s_div : ndarray + Divergence signal of length G.N living on the nodes. + + See also + -------- + compute_differential_operator + grad : compute the gradient + + Examples + -------- + >>> import numpy as np + >>> from pygsp import graphs + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> s = np.random.normal(size=G.Ne//2) # Symmetric weight matrix. + >>> s_div = G.div(s) + >>> s_grad = G.grad(s_div) + + """ + if self.Ne != 2 * s.shape[0]: + raise ValueError('Signal length should be the number of edges.') + return self.D.T.dot(s) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 20c3b380..e98fa596 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -6,10 +6,10 @@ from scipy import sparse from pygsp import utils -from . import fourier # prevent circular import in Python < 3.5 +from . import fourier, difference # prevent circular import in Python < 3.5 -class Graph(fourier.GraphFourier): +class Graph(fourier.GraphFourier, difference.GraphDifference): r""" The base graph class. @@ -713,8 +713,7 @@ def compute_differential_operator(self): where :math:`D` is the differential operator and :math:`L` is the graph Laplacian. It is used to compute the gradient and the divergence of a - graph signal, see :func:`pygsp.operators.grad` and - :func:`pygsp.operators.div`. + graph signal, see :meth:`grad` and :meth:`div`. The result is cached and accessible by the :py:attr:`D` property. diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py index d66ccf45..7eee00cb 100644 --- a/pygsp/operators/__init__.py +++ b/pygsp/operators/__init__.py @@ -3,11 +3,6 @@ r""" The :mod:`pygsp.operators` module implements some operators on graphs. -**Differential operators** - -* :func:`grad`: compute the gradient of a graph signal -* :func:`div`: compute the divergence of a graph signal - **Reduction** Functionalities for the reduction of graphs' vertex set while keeping the graph structure. * :func:`tree_multiresolution`: compute a multiresolution of trees @@ -23,10 +18,6 @@ from pygsp import utils as _utils -_DIFFERENCE = [ - 'grad', - 'div', -] _REDUCTION = [ 'tree_multiresolution', 'graph_multiresolution', @@ -38,7 +29,6 @@ 'graph_sparsify', ] -__all__ = _DIFFERENCE + _REDUCTION +__all__ = _REDUCTION -_utils.import_functions(_DIFFERENCE, 'operators.difference', 'operators') _utils.import_functions(_REDUCTION, 'operators.reduction', 'operators') diff --git a/pygsp/operators/difference.py b/pygsp/operators/difference.py deleted file mode 100644 index 2faf3682..00000000 --- a/pygsp/operators/difference.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- - -from pygsp import utils - - -logger = utils.build_logger(__name__) - - -def div(G, s): - r""" - Compute the graph divergence of a signal. - - The divergence of a signal :math:`s` is defined as - - .. math:: y = D^T s, - - where :math:`D` is the differential operator - :py:attr:`pygsp.graphs.Graph.D`. - - Parameters - ---------- - G : Graph - s : ndarray - Signal of length G.Ne/2 living on the edges (non-directed graph). - - Returns - ------- - s_div : ndarray - Divergence signal of length G.N living on the nodes. - - Examples - -------- - >>> import numpy as np - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> G.N, G.Ne - (1130, 6262) - >>> s = np.random.normal(size=G.Ne//2) # Symmetric weight matrix. - >>> s_div = operators.div(G, s) - >>> s_grad = operators.grad(G, s_div) - - """ - if G.Ne != 2 * s.shape[0]: - raise ValueError('Signal length should be the number of edges.') - return G.D.T.dot(s) - - -def grad(G, s): - r""" - Compute the graph gradient of a signal. - - The gradient of a signal :math:`s` is defined as - - .. math:: y = D s, - - where :math:`D` is the differential operator - :py:attr:`pygsp.graphs.Graph.D`. - - Parameters - ---------- - G : Graph - s : ndarray - Signal of length G.N living on the nodes. - - Returns - ------- - s_grad : ndarray - Gradient signal of length G.Ne/2 living on the edges (non-directed - graph). - - Examples - -------- - >>> import numpy as np - >>> from pygsp import graphs, operators - >>> G = graphs.Logo() - >>> G.N, G.Ne - (1130, 6262) - >>> s = np.random.normal(size=G.N) - >>> s_grad = operators.grad(G, s) - >>> s_div = operators.div(G, s_grad) - >>> np.linalg.norm(s_div - G.L.dot(s)) < 1e-10 - True - - """ - if G.N != s.shape[0]: - raise ValueError('Signal length should be the number of nodes.') - return G.D.dot(s) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index ae069f35..ac53e714 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -5,7 +5,7 @@ graphs. """ -from pygsp import operators, utils +from pygsp import utils logger = utils.build_logger(__name__) @@ -17,7 +17,7 @@ def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix This function computes the TV proximal operator for graphs. The TV norm is the one norm of the gradient. The gradient is defined in the - function :func:`pygsp.operators.grad`. + function :meth:`pygsp.graphs.Graph.grad`. This function requires the PyUNLocBoX to be executed. This function solves: @@ -77,9 +77,9 @@ def l1_at(x): return G.Diff * At(D.T * x) else: def l1_a(x): - return operators.grad(G, A(x)) + return G.grad(A(x)) def l1_at(x): - return operators.div(G, x) + return G.div(x) pyunlocbox.prox_l1(x, gamma, A=l1_a, At=l1_at, tight=tight, maxit=maxit, verbose=verbose, tol=tol) diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index 0eff6d4e..7b4c0842 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -10,7 +10,7 @@ import unittest import doctest -from pygsp.tests import test_graphs, test_filters, test_operators +from pygsp.tests import test_graphs, test_filters from pygsp.tests import test_utils, test_plotting @@ -29,7 +29,6 @@ def test_docstrings(root, ext): suites = [] suites.append(test_graphs.suite) suites.append(test_filters.suite) -suites.append(test_operators.suite) suites.append(test_utils.suite) suites.append(test_docstrings('pygsp', '.py')) suites.append(test_docstrings('.', '.rst')) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index ffbd1c9a..4e3eb2b7 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -97,6 +97,13 @@ def test_differential_operator(self): G = graphs.StochasticBlockModel(undirected=False) self.assertRaises(NotImplementedError, G.compute_differential_operator) + def test_difference(self): + for lap_type in ['combinatorial', 'normalized']: + G = graphs.Logo(lap_type=lap_type) + s_grad = G.grad(self._signal) + Ls = G.div(s_grad) + np.testing.assert_allclose(Ls, G.L.dot(self._signal)) + def test_set_coordinates(self): G = graphs.FullConnected() coords = np.random.uniform(size=(G.N, 2)) diff --git a/pygsp/tests/test_operators.py b/pygsp/tests/test_operators.py deleted file mode 100644 index aed7d3de..00000000 --- a/pygsp/tests/test_operators.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Test suite for the operators module of the pygsp package. - -""" - -import unittest - -import numpy as np - -from pygsp import graphs, operators - - -class TestCase(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.G = graphs.Logo() - cls.G.compute_fourier_basis() - - rs = np.random.RandomState(42) - cls.signal = rs.uniform(size=cls.G.N) - - @classmethod - def tearDownClass(cls): - pass - - def test_difference(self): - for lap_type in ['combinatorial', 'normalized']: - G = graphs.Logo(lap_type=lap_type) - s_grad = operators.grad(G, self.signal) - Ls = operators.div(G, s_grad) - np.testing.assert_allclose(Ls, G.L.dot(self.signal)) - - -suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From ab88ee18fc6ccde0112a8708f92b41cee04c47c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 16:30:35 +0200 Subject: [PATCH 209/392] move differential stuff in difference.py --- pygsp/graphs/difference.py | 68 ++++++++++++++++++++++++++++++++++++++ pygsp/graphs/graph.py | 60 --------------------------------- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index b0538fa3..333b3856 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- +import numpy as np +from scipy import sparse + from pygsp import utils @@ -8,6 +11,71 @@ class GraphDifference(object): + @property + def D(self): + r""" + Difference operator of the graph. + Is computed by :func:`compute_differential_operator`. + """ + if not hasattr(self, '_D'): + self.logger.warning('The differential operator G.D is not ' + 'available, we need to compute it. Explicitly ' + 'call G.compute_differential_operator() ' + 'once beforehand to suppress the warning.') + self.compute_differential_operator() + return self._D + + def compute_differential_operator(self): + r""" + Compute the graph differential operator. + + The differential operator is a matrix such that + + .. math:: L = D^T D, + + where :math:`D` is the differential operator and :math:`L` is the graph + Laplacian. It is used to compute the gradient and the divergence of a + graph signal, see :meth:`grad` and :meth:`div`. + + The result is cached and accessible by the :py:attr:`D` property. + + See also + -------- + grad : compute the gradient + div : compute the divergence + + Examples + -------- + >>> from pygsp import graphs + >>> G = graphs.Logo() + >>> G.N, G.Ne + (1130, 6262) + >>> G.compute_differential_operator() + >>> G.D.shape == (G.Ne//2, G.N) + True + + """ + + v_in, v_out, weights = self.get_edge_list() + + n = len(v_in) + Dr = np.concatenate((np.arange(n), np.arange(n))) + Dc = np.empty(2*n) + Dc[:n] = v_in + Dc[n:] = v_out + Dv = np.empty(2*n) + + if self.lap_type == 'combinatorial': + Dv[:n] = np.sqrt(weights) + Dv[n:] = -Dv[:n] + elif self.lap_type == 'normalized': + Dv[:n] = np.sqrt(weights / self.d[v_in]) + Dv[n:] = -np.sqrt(weights / self.d[v_out]) + else: + raise ValueError('Unknown lap_type {}'.format(self.lap_type)) + + self._D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, self.N)) + def grad(self, s): r""" Compute the graph gradient of a signal. diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e98fa596..d93f94c7 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -689,66 +689,6 @@ def estimate_lmax(self, recompute=False): lmax = np.real(lmax) self._lmax = lmax.sum() - @property - def D(self): - r""" - Difference operator of the graph. - Is computed by :func:`compute_differential_operator`. - """ - if not hasattr(self, '_D'): - self.logger.warning('The differential operator G.D is not ' - 'available, we need to compute it. Explicitly ' - 'call G.compute_differential_operator() ' - 'once beforehand to suppress the warning.') - self.compute_differential_operator() - return self._D - - def compute_differential_operator(self): - r""" - Compute the graph differential operator. - - The differential operator is a matrix such that - - .. math:: L = D^T D, - - where :math:`D` is the differential operator and :math:`L` is the graph - Laplacian. It is used to compute the gradient and the divergence of a - graph signal, see :meth:`grad` and :meth:`div`. - - The result is cached and accessible by the :py:attr:`D` property. - - Examples - -------- - >>> from pygsp import graphs - >>> G = graphs.Logo() - >>> G.N, G.Ne - (1130, 6262) - >>> G.compute_differential_operator() - >>> G.D.shape == (G.Ne//2, G.N) - True - - """ - - v_in, v_out, weights = self.get_edge_list() - - n = len(v_in) - Dr = np.concatenate((np.arange(n), np.arange(n))) - Dc = np.empty(2*n) - Dc[:n] = v_in - Dc[n:] = v_out - Dv = np.empty(2*n) - - if self.lap_type == 'combinatorial': - Dv[:n] = np.sqrt(weights) - Dv[n:] = -Dv[:n] - elif self.lap_type == 'normalized': - Dv[:n] = np.sqrt(weights / self.d[v_in]) - Dv[n:] = -np.sqrt(weights / self.d[v_out]) - else: - raise ValueError('Unknown lap_type {}'.format(self.lap_type)) - - self._D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, self.N)) - def get_edge_list(self): r""" Return an edge list, an alternative representation of the graph. From 46c8dc1be585b58b96aee79835cc4b449d7a0bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 16:40:54 +0200 Subject: [PATCH 210/392] reduction is now its own module --- doc/reference/index.rst | 2 +- .../{operators.rst => reduction.rst} | 4 +-- doc/tutorials/pyramid.rst | 12 +++---- pygsp/__init__.py | 7 ++-- pygsp/operators/__init__.py | 34 ------------------- pygsp/{operators => }/reduction.py | 22 +++++++++--- setup.py | 8 +++-- 7 files changed, 35 insertions(+), 54 deletions(-) rename doc/reference/{operators.rst => reduction.rst} (65%) delete mode 100644 pygsp/operators/__init__.py rename pygsp/{operators => }/reduction.py (96%) diff --git a/doc/reference/index.rst b/doc/reference/index.rst index a963d05f..a9ae9bec 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -9,8 +9,8 @@ Reference guide graphs filters - operators plotting + reduction features optimization utils diff --git a/doc/reference/operators.rst b/doc/reference/reduction.rst similarity index 65% rename from doc/reference/operators.rst rename to doc/reference/reduction.rst index 68b90a46..cb7ca16c 100644 --- a/doc/reference/operators.rst +++ b/doc/reference/reduction.rst @@ -1,8 +1,8 @@ ========= -Operators +Reduction ========= -.. automodule:: pygsp.operators +.. automodule:: pygsp.reduction :members: :undoc-members: :inherited-members: diff --git a/doc/tutorials/pyramid.rst b/doc/tutorials/pyramid.rst index 5a9fe8a0..e21c82d9 100644 --- a/doc/tutorials/pyramid.rst +++ b/doc/tutorials/pyramid.rst @@ -6,7 +6,7 @@ In this demonstration file, we show how to reduce a graph using the PyGSP. Then To start open a python shell (IPython is recommended here) and import the required packages. You would probably also import numpy as you will need it to create matrices and arrays. >>> import numpy as np ->>> from pygsp import graphs, operators +>>> from pygsp import graphs, reduction For this demo we will be using a sensor graph with 512 nodes. @@ -16,7 +16,7 @@ For this demo we will be using a sensor graph with 512 nodes. The function graph_multiresolution computes the graph pyramid for you: >>> levels = 5 ->>> Gs = operators.graph_multiresolution(G, levels, sparsify=False) +>>> Gs = reduction.graph_multiresolution(G, levels, sparsify=False) Next, we will compute the fourier basis of our different graph layers: @@ -38,13 +38,13 @@ Let's now create two signals and a filter, resp f, f2 and g: We will run the analysis of the two signals on the pyramid and obtain a coarse approximation for each layer, with decreasing number of nodes. Additionally, we will also get prediction errors at each node at every layer. ->>> ca, pe = operators.pyramid_analysis(Gs, f, h_filters=g, method='exact') ->>> ca2, pe2 = operators.pyramid_analysis(Gs, f2, h_filters=g, method='exact') +>>> ca, pe = reduction.pyramid_analysis(Gs, f, h_filters=g, method='exact') +>>> ca2, pe2 = reduction.pyramid_analysis(Gs, f2, h_filters=g, method='exact') Given the pyramid, the coarsest approximation and the prediction errors, we will now reconstruct the original signal on the full graph. ->>> f_pred, _ = operators.pyramid_synthesis(Gs, ca[levels], pe, method='exact') ->>> f_pred2, _ = operators.pyramid_synthesis(Gs, ca2[levels], pe2, method='exact') +>>> f_pred, _ = reduction.pyramid_synthesis(Gs, ca[levels], pe, method='exact') +>>> f_pred2, _ = reduction.pyramid_synthesis(Gs, ca2[levels], pe2, method='exact') Here are the final errors for each signal after reconstruction. diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 3db94627..dbb602df 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- r""" -The :mod:`pygsp` package is mainly organized around the following three -modules: +The :mod:`pygsp` package is mainly organized around the following two modules: * :mod:`.graphs` to create and manipulate various kinds of graphs, * :mod:`.filters` to create and manipulate various graph filters, -* :mod:`.operators` to apply various operators to graph signals. Moreover, the following modules provide additional functionality: * :mod:`.plotting` to plot, +* :mod:`.reduction` to reduce a graph while keeping its structure, * :mod:`.features` to compute features on graphs, * :mod:`.optimization` to help solving convex optimization problems, * :mod:`.utils` for various utilities. @@ -22,8 +21,8 @@ __all__ = [ 'graphs', 'filters', - 'operators', 'plotting', + 'reduction', 'features', 'optimization', 'utils', diff --git a/pygsp/operators/__init__.py b/pygsp/operators/__init__.py deleted file mode 100644 index 7eee00cb..00000000 --- a/pygsp/operators/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- - -r""" -The :mod:`pygsp.operators` module implements some operators on graphs. - -**Reduction** Functionalities for the reduction of graphs' vertex set while keeping the graph structure. - -* :func:`tree_multiresolution`: compute a multiresolution of trees -* :func:`graph_multiresolution`: compute a pyramid of graphs -* :func:`kron_reduction`: compute the Kron reduction -* :func:`pyramid_analysis`: analysis operator for graph pyramid -* :func:`pyramid_synthesis`: synthesis operator for graph pyramid -* :func:`pyramid_cell2coeff`: keep only the necessary coefficients -* :func:`interpolate`: interpolate a signal -* :func:`graph_sparsify`: sparsify a graph - -""" - -from pygsp import utils as _utils - -_REDUCTION = [ - 'tree_multiresolution', - 'graph_multiresolution', - 'kron_reduction', - 'pyramid_analysis', - 'pyramid_synthesis', - 'pyramid_cell2coeff', - 'interpolate', - 'graph_sparsify', -] - -__all__ = _REDUCTION - -_utils.import_functions(_REDUCTION, 'operators.reduction', 'operators') diff --git a/pygsp/operators/reduction.py b/pygsp/reduction.py similarity index 96% rename from pygsp/operators/reduction.py rename to pygsp/reduction.py index 533aa8f0..7b70bff7 100644 --- a/pygsp/operators/reduction.py +++ b/pygsp/reduction.py @@ -1,5 +1,19 @@ # -*- coding: utf-8 -*- +r""" +The :mod:`pygsp.reduction` module implements functionalities for the reduction +of graphs' vertex set while keeping the graph structure. + +* :func:`tree_multiresolution`: compute a multiresolution of trees +* :func:`graph_multiresolution`: compute a pyramid of graphs +* :func:`kron_reduction`: compute the Kron reduction +* :func:`pyramid_analysis`: analysis operator for graph pyramid +* :func:`pyramid_synthesis`: synthesis operator for graph pyramid +* :func:`pyramid_cell2coeff`: keep only the necessary coefficients +* :func:`interpolate`: interpolate a signal +* :func:`graph_sparsify`: sparsify a graph +""" + import numpy as np from scipy import sparse, stats from scipy.sparse import linalg @@ -32,10 +46,10 @@ def graph_sparsify(M, epsilon, maxiter=10): Examples -------- - >>> from pygsp import graphs, operators + >>> from pygsp import graphs, reduction >>> G = graphs.Sensor(256, Nc=20, distribute=True) >>> epsilon = 0.4 - >>> G2 = operators.graph_sparsify(G, epsilon) + >>> G2 = reduction.graph_sparsify(G, epsilon) References ---------- @@ -215,11 +229,11 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, Examples -------- - >>> from pygsp import graphs, operators + >>> from pygsp import graphs, reduction >>> levels = 5 >>> G = graphs.Sensor(N=512) >>> G.compute_fourier_basis() - >>> Gs = operators.graph_multiresolution(G, levels, sparsify=False) + >>> Gs = reduction.graph_multiresolution(G, levels, sparsify=False) >>> for idx in range(levels): ... Gs[idx].plotting['plot_name'] = 'Reduction level: {}'.format(idx) ... Gs[idx].plot() diff --git a/setup.py b/setup.py index 20920a3c..93755f5f 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,11 @@ long_description=open('README.rst').read(), author='EPFL LTS2', url='https://github.com/epfl-lts2/pygsp', - packages=['pygsp', 'pygsp.filters', - 'pygsp.graphs', 'pygsp.graphs.nngraphs', - 'pygsp.operators', 'pygsp.tests'], + packages=['pygsp', + 'pygsp.graphs', + 'pygsp.graphs.nngraphs', + 'pygsp.filters', + 'pygsp.tests'], package_data={'pygsp': ['data/pointclouds/*.mat']}, test_suite='pygsp.tests.test_all.suite', install_requires=[ From 28a82d21ad20c704bae96ed7cccd48854f0ba34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 24 Aug 2017 17:05:49 +0200 Subject: [PATCH 211/392] graphs: doc --- pygsp/graphs/__init__.py | 43 +++++++++++++++++++++++++++++++++++----- pygsp/graphs/fourier.py | 10 +++++----- pygsp/graphs/graph.py | 7 ------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 4eb47867..0f017eba 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -6,7 +6,28 @@ of the built-in graph models. The :class:`Graph` base class allows to construct a graph object from any -adjacency matrix and provides a common interface to that object. +adjacency matrix and provides a common interface to that object. Derived +classes then allows to instantiate various standard graph models. + +**Matrix operators** + +* :attr:`Graph.W`: weight matrix +* :attr:`Graph.L`: Laplacian +* :attr:`Graph.U`: Fourier basis +* :attr:`Graph.D`: differential operator + +**Checks** + +* :meth:`Graph.check_weights`: check the characteristics of the weights matrix +* :meth:`Graph.is_connected`: check the strong connectivity of the input graph +* :meth:`Graph.is_directed`: check if the graph has directed edges + +**Attributes computation** + +* :meth:`Graph.compute_laplacian`: compute a graph Laplacian +* :meth:`Graph.estimate_lmax`: estimate largest eigenvalue +* :meth:`Graph.compute_fourier_basis`: compute Fourier basis +* :meth:`Graph.compute_differential_operator`: compute differential operator **Differential operators** @@ -18,7 +39,7 @@ * :meth:`Graph.modulate`: generalized modulation operator * :meth:`Graph.translate`: generalized translation operator -**Fourier basis and transforms** (frequency and vertex-frequency) +**Transforms** (frequency and vertex-frequency) * :meth:`Graph.gft`: graph Fourier transform (GFT) * :meth:`Graph.igft`: inverse graph Fourier transform @@ -26,7 +47,20 @@ * :meth:`Graph.gft_windowed_gabor`: Gabor windowed GFT * :meth:`Graph.gft_windowed_normalized`: normalized windowed GFT -Derived classes implement various graph models. +**Plotting** + +* :meth:`Graph.plot`: plot the graph +* :meth:`Graph.plot_signal`: plot a signal on that graph +* :meth:`Graph.plot_spectrogram`: plot the spectrogram for the graph object + +**Others** + +* :meth:`Graph.get_edge_list`: return an edge list (alternative representation) +* :meth:`Graph.set_coordinates`: set nodes' coordinates (for plotting) +* :meth:`Graph.subgraph`: create a subgraph +* :meth:`Graph.extract_components`: split the graph into connected components + +**Graph models** * :class:`Airfoil` * :class:`BarabasiAlbert` @@ -48,8 +82,7 @@ * :class:`SwissRoll` * :class:`Torus` -Derived classes from :class:`NNGraph` implement nearest-neighbors graphs -constructed from point clouds. +**Nearest-neighbors graphs constructed from point clouds** * :class:`Bunny` * :class:`Cube` diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 5f074e98..b5e90a5a 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -142,7 +142,7 @@ def gft(self, s): Examples -------- >>> import numpy as np - >>> from pygsp import graphs, operators + >>> from pygsp import graphs >>> G = graphs.Logo() >>> s = np.random.normal(size=G.N) >>> s_hat = G.gft(s) @@ -177,7 +177,7 @@ def igft(self, s_hat): Examples -------- >>> import numpy as np - >>> from pygsp import graphs, operators + >>> from pygsp import graphs >>> G = graphs.Logo() >>> s_hat = np.random.normal(size=G.N) >>> s = G.igft(s_hat) @@ -234,7 +234,7 @@ def gft_windowed_gabor(self, f, k): Examples -------- >>> import numpy as np - >>> from pygsp import graphs, operators + >>> from pygsp import graphs >>> G = graphs.Logo() >>> s = np.random.normal(size=G.N) >>> C = G.gft_windowed_gabor(s, lambda x: x/(1.-x)) @@ -292,7 +292,7 @@ def gft_windowed(self, g, f, lowmemory=True): else: # Compute the translate of g - # TODO: use operators.translate() + # TODO: use self.translate() ghat = np.dot(U.T, g) Ftrans = np.sqrt(N) * np.dot(U, (np.kron(np.ones((N)), ghat)*U.T)) C = np.empty((N, N)) @@ -336,7 +336,7 @@ def gft_windowed_normalized(self, g, f, lowmemory=True): else: # Compute the translate of g - # TODO: use operators.translate() + # TODO: use self.translate() ghat = np.dot(U.T, g) Ftrans = np.sqrt(N)*np.dot(U, (np.kron(np.ones((1, N)), ghat)*U.T)) C = np.empty((N, N)) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index d93f94c7..8748e668 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -17,13 +17,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): * Can be instantiated to construct custom graphs from a weight matrix. * Initialize attributes for derived classes. - The following operators are available as matrices: - - * :py:attr:`W`: weight matrix - * :py:attr:`L`: Laplacian - * :py:attr:`U`: Fourier basis - * :py:attr:`D`: differential operator - Parameters ---------- W : sparse matrix or ndarray From 611d3ed0a76ac93ab3e3d48b3fef475e26b94259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 25 Aug 2017 09:30:23 +0200 Subject: [PATCH 212/392] tutorials: graph TV not ready --- doc/tutorials/graph_tv.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst index f4adf7cc..ea5ae869 100644 --- a/doc/tutorials/graph_tv.rst +++ b/doc/tutorials/graph_tv.rst @@ -2,6 +2,13 @@ Reconstruction of missing samples with graph TV =============================================== +.. note:: + The toolbox is **not ready** (yet?) for the completion of that tutorial. + For one, the proximal TV operator on graph is missing. + Please see the `matlab version of that tutorial + `_. + If you like it, implement it! + Description ----------- From ded484f0fc55b2af083f0c54e5884ffeb2a0a959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 25 Aug 2017 10:57:53 +0200 Subject: [PATCH 213/392] show plots by default (issue #14) --- pygsp/plotting.py | 86 ++++++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 4b2a59dd..367db883 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -72,7 +72,7 @@ def close_all(): _plt_figures = [] -def show(block=False, **kwargs): +def show(*args, **kwargs): r""" Show created figures. @@ -80,7 +80,7 @@ def show(block=False, **kwargs): By default, showing plots does not block the prompt. """ - plt.show(block, **kwargs) + plt.show(*args, **kwargs) def close(*args, **kwargs): @@ -138,11 +138,8 @@ def plot_graph(G, backend=None, **kwargs): whether the plot is saved as plot_name.png and plot_name.pdf (True) or shown in a window (False) (default False). Only available with the matplotlib backend. - show_plot : boolean - whether to show the plot, i.e. call plt.show(). Only available with the - matplotlib backend. ax : matplotlib.axes - Axes to where to draw the graph. Optional, created if not passed. Only + Axes where to draw the graph. Optional, created if not passed. Only available with the matplotlib backend. Examples @@ -169,8 +166,7 @@ def plot_graph(G, backend=None, **kwargs): raise ValueError('The {} backend is not available.'.format(backend)) -def _plt_plot_graph(G, savefig=False, show_edges=None, - show_plot=True, plot_name='', ax=None): +def _plt_plot_graph(G, savefig=False, show_edges=None, plot_name='', ax=None): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph @@ -275,12 +271,15 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, except KeyError: pass - # Save plot as PNG or show it in a window - if savefig: - plt.savefig(plot_name + '.png') - plt.savefig(plot_name + '.pdf') - elif show_plot: - plt.show(False) # non blocking show + try: + if savefig: + fig.savefig(plot_name + '.png') + fig.savefig(plot_name + '.pdf') + else: + fig.show(warn=False) + except NameError: + # No figure created, an axis was passed. + pass # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() @@ -397,7 +396,7 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x_size=10, plot_eigenvalues=None, show_sum=None, - savefig=False, show_plot=False, plot_name=None): + savefig=False, plot_name=None, ax=None): r""" Plot a filter bank, i.e. a set of graph filters. @@ -426,6 +425,9 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, whether the plot is saved as plot_name.png and plot_name.pdf (True) or shown in a window (False) (default False). Only available with the matplotlib backend. + ax : matplotlib.axes + Axes where to draw the graph. Optional, created if not passed. Only + available with the matplotlib backend. Examples -------- @@ -453,16 +455,17 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, fd = filters.evaluate(lambdas) # Plot the filter - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) - size = len(fd) - ax = fig.add_subplot(111) - if len(filters.g) == 1: + if not ax: + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) + ax = fig.add_subplot(111) + + if filters.Nf == 1: ax.plot(lambdas, fd, linewidth=line_width) - elif len(filters.g) > 1: - for i in range(size): - ax.plot(lambdas, fd[i], linewidth=line_width) + elif filters.Nf > 1: + for fd_i in fd: + ax.plot(lambdas, fd_i, linewidth=line_width) # Plot eigenvalues if plot_eigenvalues: @@ -476,12 +479,15 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, test_sum = np.sum(np.power(fd, 2), 0) ax.plot(lambdas, test_sum, 'k', linewidth=line_width) - # Save plot as PNG or show it in a window - if savefig: - plt.savefig(plot_name + '.png') - plt.savefig(plot_name + '.pdf') - elif show_plot: - plt.show(False) # non blocking show + try: + if savefig: + fig.savefig(plot_name + '.png') + fig.savefig(plot_name + '.pdf') + else: + fig.show(warn=False) + except NameError: + # No figure created, an axis was passed. + pass def plot_signal(G, signal, backend=None, **kwargs): @@ -521,7 +527,7 @@ def plot_signal(G, signal, backend=None, **kwargs): shown in a window (False) (default False). Only available with the matplotlib backend. ax : matplotlib.axes - Axes to where to draw the graph. Optional, created if not passed. Only + Axes where to draw the graph. Optional, created if not passed. Only available with the matplotlib backend. Examples @@ -551,7 +557,7 @@ def plot_signal(G, signal, backend=None, **kwargs): def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], vertex_size=None, vertex_highlight=False, climits=None, colorbar=True, bar=False, bar_width=1, savefig=False, - show_plot=False, plot_name=None, ax=None): + plot_name=None, ax=None): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") @@ -589,7 +595,6 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), np.expand_dims(G.coords[kj, 1], axis=0))) ax.plot(x, y, color='grey', zorder=1) - # plt.show() if G.coords.shape[1] == 3: # Very dirty way to display 3D graph edges x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), @@ -629,12 +634,15 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], except KeyError: pass - # Save plot as PNG or show it in a window - if savefig: - plt.savefig(plot_name + '.png') - plt.savefig(plot_name + '.pdf') - elif show_plot: - plt.show(False) # non blocking show + try: + if savefig: + fig.savefig(plot_name + '.png') + fig.savefig(plot_name + '.pdf') + else: + fig.show(warn=False) + except NameError: + # No figure created, an axis was passed. + pass def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], From a60e4c944485f2d75dc25c612230d4a4fce1f530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 25 Aug 2017 12:32:32 +0200 Subject: [PATCH 214/392] heat filter: docstring --- pygsp/filters/heat.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 41f26222..df522c16 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -18,10 +18,9 @@ class Heat(Filter): ---------- G : graph tau : int or list of ints - Scaling parameter. (default = 10) + Scaling parameter. normalize : bool - Normalize the kernel (works only if the eigenvalues are - present in the graph). (default = 0) + Normalizes the kernel. Needs the eigenvalues. Examples -------- From adcd4b22079c1590d8200fb63e422a59d1b5d3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 25 Aug 2017 13:35:37 +0200 Subject: [PATCH 215/392] plotting: colorbar and limits --- pygsp/plotting.py | 60 +++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 367db883..596d21e7 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -503,27 +503,29 @@ def plot_signal(G, signal, backend=None, **kwargs): show_edges : boolean Set to False to only draw the vertices (default G.Ne < 10000). cp : list of int - Camera position for a 3D graph. + NOT IMPLEMENTED. Camera position when plotting a 3D graph. vertex_size : int Size of circle representing each signal component. vertex_highlight : list of boolean - Vector of indices for vertices to be highlighted. - climits : list of int - Limits of the colorbar. - colorbar : boolean - To plot an extra line showing the sum of the squared magnitudes - of the filters (default True if there is multiple filters). + NOT IMPLEMENTED. Vector of indices for vertices to be highlighted. + colorbar : bool + Whether to plot a colorbar indicating the signal's amplitude. + Only available with the matplotlib backend. + limits : [vmin, vmax] + Maps colors from vmin to vmax. + Defaults to signal minimum and maximum value. + Only available with the matplotlib backend. bar : boolean NOT IMPLEMENTED: False display color, True display bar for the graph (default False). bar_width : int - Width of the bar (default 1). + NOT IMPLEMENTED. Width of the bar (default 1). backend: {'matplotlib', 'pyqtgraph'} Defines the drawing backend to use. Defaults to :data:`BACKEND`. plot_name : string - name of the plot + Name of the plot. savefig : boolean - whether the plot is saved as plot_name.png and plot_name.pdf (True) or + Whether the plot is saved as plot_name.png and plot_name.pdf (True) or shown in a window (False) (default False). Only available with the matplotlib backend. ax : matplotlib.axes @@ -554,23 +556,17 @@ def plot_signal(G, signal, backend=None, **kwargs): raise ValueError('The {} backend is not available.'.format(backend)) -def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], - vertex_size=None, vertex_highlight=False, climits=None, - colorbar=True, bar=False, bar_width=1, savefig=False, - plot_name=None, ax=None): +def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, + colorbar=True, savefig=False, plot_name=None, ax=None): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") if show_edges is None: show_edges = G.Ne < 10000 - if vertex_size is None: - vertex_size = 100 - if climits is None: - cmin = 1.01 * np.min(signal) - cmax = 1.01 * np.max(signal) - climits = [cmin, cmax] if plot_name is None: plot_name = "Signal plot of " + G.gtype + if limits is None: + limits = [signal.min(), signal.max()] if not ax: fig = plt.figure() @@ -622,11 +618,13 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], # Plot signal if G.coords.shape[1] == 2: - ax.scatter(G.coords[:, 0], G.coords[:, 1], s=vertex_size, c=signal, - zorder=2) + sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], + s=vertex_size, c=signal, zorder=2, + vmin=limits[0], vmax=limits[1]) if G.coords.shape[1] == 3: - ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], - s=vertex_size, c=signal, zorder=2) + sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], + s=vertex_size, c=signal, zorder=2, + vmin=limits[0], vmax=limits[1]) try: ax.view_init(elev=G.plotting['elevation'], azim=G.plotting['azimuth']) @@ -634,6 +632,9 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], except KeyError: pass + if colorbar: + plt.colorbar(sc, ax=ax) + try: if savefig: fig.savefig(plot_name + '.png') @@ -645,21 +646,14 @@ def _plt_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], pass -def _qtg_plot_signal(G, signal, show_edges=None, cp=[-6, -3, 160], - vertex_size=None, vertex_highlight=False, climits=None, - colorbar=True, bar=False, bar_width=1, plot_name=None): +def _qtg_plot_signal(G, signal, show_edges=None, + vertex_size=15, plot_name=None): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") if show_edges is None: show_edges = G.Ne < 10000 - if vertex_size is None: - vertex_size = 15 - if climits is None: - cmin = 1.01 * np.min(signal) - cmax = 1.01 * np.max(signal) - climits = [cmin, cmax] if G.coords.shape[1] == 2: window = qtg.GraphicsWindow(plot_name or G.gtype) From e0201a58d85b6e74214168dbc5a24b5655b5e8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 25 Aug 2017 16:38:08 +0200 Subject: [PATCH 216/392] readme: need recent pip --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index 57754d2f..37ab5f9e 100644 --- a/README.rst +++ b/README.rst @@ -98,6 +98,9 @@ The PyGSP is available on PyPI:: $ pip install pygsp +Note that you will need a recent version of ``pip``. +Please run ``pip install --upgrade pip`` if you get an installation error. + Contributing ------------ From f4d60608f6011de0b8d9be58a9d452ce9176db19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 13:47:54 +0200 Subject: [PATCH 217/392] plotting: factor ax creation and saving --- pygsp/plotting.py | 134 ++++++++++++++--------------------- pygsp/tests/test_plotting.py | 9 +++ 2 files changed, 64 insertions(+), 79 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 596d21e7..9d93a83c 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -48,6 +48,40 @@ _plt_figures = [] +def _plt_handle_figure(plot): + + def inner(obj, *args, **kwargs): + + # Create a figure and an axis if none were passed. + if not 'ax' in kwargs.keys(): + fig = plt.figure() + global _plt_figures + _plt_figures.append(fig) + + if hasattr(obj, 'coords') and obj.coords.shape[1] == 3: + ax = fig.add_subplot(111, projection='3d') + else: + ax = fig.add_subplot(111) + + kwargs.update(ax=ax) + + save_as = kwargs.pop('save_as', None) + + plot(obj, *args, **kwargs) + + try: + if save_as is not None: + fig.savefig(save_as + '.png') + fig.savefig(save_as + '.pdf') + else: + fig.show(warn=False) + except NameError: + # No figure created, an axis was passed. + pass + + return inner + + def close_all(): r""" Close all opened windows. @@ -132,12 +166,11 @@ def plot_graph(G, backend=None, **kwargs): Set to False to only draw the vertices (default G.Ne < 10000). backend: {'matplotlib', 'pyqtgraph'} Defines the drawing backend to use. Defaults to :data:`BACKEND`. - plot_name : string + plot_name : str name of the plot - savefig : boolean - whether the plot is saved as plot_name.png and plot_name.pdf (True) or - shown in a window (False) (default False). Only available with the - matplotlib backend. + save_as : str + Whether to save the plot as save_as.png and save_as.pdf. Shown in a + window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. Only available with the matplotlib backend. @@ -166,7 +199,8 @@ def plot_graph(G, backend=None, **kwargs): raise ValueError('The {} backend is not available.'.format(backend)) -def _plt_plot_graph(G, savefig=False, show_edges=None, plot_name='', ax=None): +@_plt_handle_figure +def _plt_plot_graph(G, show_edges=None, plot_name='', ax=None): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph @@ -187,16 +221,6 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, plot_name='', ax=None): except KeyError: edge_color = np.array([255, 88, 41]) / 255. - if not ax: - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) - - if G.coords.shape[1] == 2: - ax = fig.add_subplot(111) - elif G.coords.shape[1] == 3: - ax = fig.add_subplot(111, projection='3d') - if show_edges: ki, kj = np.nonzero(G.A) if G.is_directed(): @@ -271,16 +295,6 @@ def _plt_plot_graph(G, savefig=False, show_edges=None, plot_name='', ax=None): except KeyError: pass - try: - if savefig: - fig.savefig(plot_name + '.png') - fig.savefig(plot_name + '.pdf') - else: - fig.show(warn=False) - except NameError: - # No figure created, an axis was passed. - pass - # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() @@ -394,16 +408,17 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): _qtg_widgets.append(widget) +@_plt_handle_figure def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x_size=10, plot_eigenvalues=None, show_sum=None, - savefig=False, plot_name=None, ax=None): + plot_name=None, ax=None): r""" - Plot a filter bank, i.e. a set of graph filters. + Plot the spectral response of a filter bank, a set of graph filters. Parameters ---------- filters : Filter - Filter to plot. + Filter bank to plot. npoints : int Number of point where the filters are evaluated. line_width : int @@ -421,10 +436,9 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, of the filters (default True if there is multiple filters). plot_name : string name of the plot - savefig : boolean - whether the plot is saved as plot_name.png and plot_name.pdf (True) or - shown in a window (False) (default False). Only available with the - matplotlib backend. + save_as : str + Whether to save the plot as save_as.png and save_as.pdf. Shown in a + window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. Only available with the matplotlib backend. @@ -440,12 +454,10 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, G = filters.G - if not isinstance(filters.g, list): - filters.g = [filters.g] if plot_eigenvalues is None: plot_eigenvalues = hasattr(G, '_e') if show_sum is None: - show_sum = len(filters.g) > 1 + show_sum = filters.Nf > 1 if plot_name is None: plot_name = u"Filter plot of {}".format(G.gtype) @@ -455,15 +467,9 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, fd = filters.evaluate(lambdas) # Plot the filter - if not ax: - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) - ax = fig.add_subplot(111) - if filters.Nf == 1: ax.plot(lambdas, fd, linewidth=line_width) - elif filters.Nf > 1: + else: for fd_i in fd: ax.plot(lambdas, fd_i, linewidth=line_width) @@ -472,23 +478,13 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, ax.plot(G.e, np.zeros(G.N), 'xk', markeredgewidth=x_width, markersize=x_size) - # Plot highlighted eigenvalues TODO + # TODO: plot highlighted eigenvalues # Plot the sum if show_sum: test_sum = np.sum(np.power(fd, 2), 0) ax.plot(lambdas, test_sum, 'k', linewidth=line_width) - try: - if savefig: - fig.savefig(plot_name + '.png') - fig.savefig(plot_name + '.pdf') - else: - fig.show(warn=False) - except NameError: - # No figure created, an axis was passed. - pass - def plot_signal(G, signal, backend=None, **kwargs): r""" @@ -524,10 +520,9 @@ def plot_signal(G, signal, backend=None, **kwargs): Defines the drawing backend to use. Defaults to :data:`BACKEND`. plot_name : string Name of the plot. - savefig : boolean - Whether the plot is saved as plot_name.png and plot_name.pdf (True) or - shown in a window (False) (default False). Only available with the - matplotlib backend. + save_as : str + Whether to save the plot as save_as.png and save_as.pdf. Shown in a + window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. Only available with the matplotlib backend. @@ -556,8 +551,9 @@ def plot_signal(G, signal, backend=None, **kwargs): raise ValueError('The {} backend is not available.'.format(backend)) +@_plt_handle_figure def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, - colorbar=True, savefig=False, plot_name=None, ax=None): + colorbar=True, plot_name=None, ax=None): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") @@ -568,16 +564,6 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, if limits is None: limits = [signal.min(), signal.max()] - if not ax: - fig = plt.figure() - global _plt_figures - _plt_figures.append(fig) - - if G.coords.shape[1] == 2: - ax = fig.add_subplot(111) - elif G.coords.shape[1] == 3: - ax = fig.add_subplot(111, projection='3d') - if show_edges: ki, kj = np.nonzero(G.A) @@ -635,16 +621,6 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, if colorbar: plt.colorbar(sc, ax=ax) - try: - if savefig: - fig.savefig(plot_name + '.png') - fig.savefig(plot_name + '.pdf') - else: - fig.show(warn=False) - except NameError: - # No figure created, an axis was passed. - pass - def _qtg_plot_signal(G, signal, show_edges=None, vertex_size=15, plot_name=None): diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index a62758f2..efa675df 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -6,6 +6,7 @@ """ import unittest +import os import numpy as np from skimage import data, img_as_float @@ -90,4 +91,12 @@ def test_plot_graphs(self): plotting.close_all() + def test_save(self): + G = graphs.Logo() + name = 'test_plot' + G.plot(backend='matplotlib', save_as=name) + os.remove(name + '.png') + os.remove(name + '.pdf') + + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 7a4809942d3e3d2ded71ca01a13d7723d25e003e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 14:06:52 +0200 Subject: [PATCH 218/392] plotting: edge color defaults to grey --- pygsp/plotting.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 9d93a83c..ae0a35ca 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -219,7 +219,7 @@ def _plt_plot_graph(G, show_edges=None, plot_name='', ax=None): try: edge_color = G.plotting['edge_color'] except KeyError: - edge_color = np.array([255, 88, 41]) / 255. + edge_color = [0.5, 0.5, 0.5] if show_edges: ki, kj = np.nonzero(G.A) @@ -305,6 +305,11 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): if show_edges is None: show_edges = G.Ne < 10000 + try: + edge_color = G.plotting['edge_color'] + except KeyError: + edge_color = [0.5, 0.5, 0.5] + ki, kj = np.nonzero(G.A) if G.is_directed(): raise NotImplementedError @@ -326,14 +331,10 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): extra_args['symbolPen'] = G.plotting['vertex_color'] extra_args['brush'] = G.plotting['vertex_color'] - # Define syntaxic sugar mapping keywords for the display options - for plot_args, qtg_args in [('vertex_size', 'size'), ('vertex_mask', 'mask'), ('edge_color', 'pen')]: + # Define syntactic sugar mapping keywords for the display options + for plot_args, qtg_args in [('vertex_size', 'size'), ('vertex_mask', 'mask'), ('edge_color', 'pen'), ('symbolPen', 'symbolPen')]: if plot_args in G.plotting: - G.plotting[qtg_args] = G.plotting.pop(plot_args) - - for qtg_args in ['size', 'mask', 'pen', 'symbolPen']: - if qtg_args in G.plotting: - extra_args[qtg_args] = G.plotting[qtg_args] + extra_args[qtg_args] = G.plotting[plot_args] if not show_edges: extra_args['pen'] = None @@ -394,10 +395,6 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): extra_args[qtg_args] = G.plotting[qtg_args] if show_edges: - try: - edge_color = G.plotting['edge_color'] - except KeyError: - edge_color = np.array([255, 88, 41]) / 255. g = gl.GLLinePlotItem(pos=pts, mode='lines', color=edge_color) widget.addItem(g) From e152a67a59b74758ffb26f9d966bf9a04cc2c962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 14:10:42 +0200 Subject: [PATCH 219/392] plotting: labels for plot_filter --- pygsp/plotting.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index ae0a35ca..863ac1e9 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -482,6 +482,9 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, test_sum = np.sum(np.power(fd, 2), 0) ax.plot(lambdas, test_sum, 'k', linewidth=line_width) + ax.set_xlabel("laplacian's eigenvalues / graph frequencies") + ax.set_ylabel('filter response') + def plot_signal(G, signal, backend=None, **kwargs): r""" From 4dcf94677b5eaa03b2bf1f9c2049e37adddab3c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 14:24:14 +0200 Subject: [PATCH 220/392] filter synthesis: use igft conjugate had no effect as U is real --- pygsp/filters/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index ad8b3a82..879a1856 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -303,12 +303,12 @@ def synthesis(self, c, method='chebyshev', order=30): if self.Nf == 1: fc = np.tile(fie, (Nv, 1)).T * self.G.gft(c[tmpN]) - s += np.dot(np.conjugate(self.G.U), fc) + s += self.G.igft(fc) else: for i in range(self.Nf): fc = self.G.gft(c[N * i + tmpN]) fc *= np.tile(fie[:][i], (Nv, 1)).T - s += np.dot(np.conjugate(self.G.U), fc) + s += self.G.igft(fc) elif method == 'chebyshev': cheb_coeffs = approximations.compute_cheby_coeff(self, m=order, From e90eff468b9be85e66f22d49c6994ea4dd1eaa29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 20:29:47 +0200 Subject: [PATCH 221/392] heat filter: better implementation and correct normalization --- pygsp/filters/heat.py | 67 +++++++++++++++++++++---------------- pygsp/tests/test_filters.py | 5 ++- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index df522c16..f6386172 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -1,58 +1,67 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np -from numpy import linalg -from pygsp import utils from . import Filter # prevent circular import in Python < 3.5 -_logger = utils.build_logger(__name__) - - class Heat(Filter): r""" - Heat filterbank + Design an heat low-pass filter (simulates heat diffusion when applied). + + The filter is defined in the spectral domain as + + .. math:: + \hat{g}(x) = \exp \left( \frac{-\tau x}{\lambda_{\text{max}}} \right). Parameters ---------- G : graph tau : int or list of ints - Scaling parameter. + Scaling parameter. If a list, creates a filter bank with one filter per + value of tau. normalize : bool Normalizes the kernel. Needs the eigenvalues. Examples -------- + >>> import numpy as np >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> F = filters.Heat(G) + + Regular heat kernel. + + >>> g = filters.Heat(G, tau=[5, 10]) + >>> print('{} filters'.format(g.Nf)) + 2 filters + >>> y = g.evaluate(G.e) + >>> print('{:.2f}'.format(np.linalg.norm(y[0]))) + 9.76 + + Normalized heat kernel. + + >>> g = filters.Heat(G, tau=[5, 10], normalize=True) + >>> y = g.evaluate(G.e) + >>> print('{:.2f}'.format(np.linalg.norm(y[0]))) + 1.00 """ def __init__(self, G, tau=10, normalize=False, **kwargs): - g = [] + try: + iter(tau) + except TypeError: + tau = [tau] + + def kernel(x, t, norm=1): + return np.exp(-t * x / G.lmax) / norm - if normalize: - if isinstance(tau, list): - for t in tau: - def gu(x, taulam=t): - return np.exp(-taulam * x/G.lmax) - ng = linalg.norm(gu(G.e)) - g.append(lambda x, taulam=t: np.exp(-taulam * - x/G.lmax / ng)) - else: - def gu(x): - return np.exp(-tau * x/G.lmax) - ng = linalg.norm(gu(G.e)) - g.append(lambda x: np.exp(-tau * x/G.lmax / ng)) - - else: - if isinstance(tau, list): - for t in tau: - g.append(lambda x, taulam=t: np.exp(-taulam * x/G.lmax)) - else: - g.append(lambda x: np.exp(-tau * x/G.lmax)) + g = [] + for t in tau: + norm = np.linalg.norm(kernel(G.e, t)) if normalize else 1 + g.append(lambda x, t=t, norm=norm: kernel(x, t, norm)) super(Heat, self).__init__(G, g, **kwargs) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 74e64712..df6a4046 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -161,11 +161,14 @@ def test_papadakis(self): def test_heat(self): f = filters.Heat(self._G, normalize=False, tau=10) self._test_methods(f) - f = filters.Heat(self._G, normalize=False, tau=[5, 10]) + f = filters.Heat(self._G, normalize=False, tau=np.array([5, 10])) self._test_methods(f) f = filters.Heat(self._G, normalize=True, tau=10) + np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)), 1) self._test_methods(f) f = filters.Heat(self._G, normalize=True, tau=[5, 10]) + np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)[0]), 1) + np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)[1]), 1) self._test_methods(f) def test_expwin(self): From aab65e82be730a4262b77ff3678aa3d2c79f9509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 22:38:22 +0200 Subject: [PATCH 222/392] doc: show members only in base class except for Graph --- doc/reference/features.rst | 1 - doc/reference/filters.rst | 1 - doc/reference/graphs.rst | 5 ++++- doc/reference/optimization.rst | 1 - doc/reference/plotting.rst | 1 - doc/reference/reduction.rst | 1 - 6 files changed, 4 insertions(+), 6 deletions(-) diff --git a/doc/reference/features.rst b/doc/reference/features.rst index 8f08b7b6..f93de255 100644 --- a/doc/reference/features.rst +++ b/doc/reference/features.rst @@ -5,4 +5,3 @@ Features .. automodule:: pygsp.features :members: :undoc-members: - :inherited-members: diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index 3946dd74..d2b7c389 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -5,4 +5,3 @@ Filters .. automodule:: pygsp.filters :members: :undoc-members: - :inherited-members: diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index 5b5a0424..99dc07b6 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -5,4 +5,7 @@ Graphs .. automodule:: pygsp.graphs :members: :undoc-members: - :inherited-members: + :exclude-members: Graph + + .. autoclass:: Graph + :inherited-members: diff --git a/doc/reference/optimization.rst b/doc/reference/optimization.rst index 8599ead2..2fada89c 100644 --- a/doc/reference/optimization.rst +++ b/doc/reference/optimization.rst @@ -5,4 +5,3 @@ Optimization .. automodule:: pygsp.optimization :members: :undoc-members: - :inherited-members: diff --git a/doc/reference/plotting.rst b/doc/reference/plotting.rst index 435d8882..442ed6af 100644 --- a/doc/reference/plotting.rst +++ b/doc/reference/plotting.rst @@ -5,4 +5,3 @@ Plotting .. automodule:: pygsp.plotting :members: :undoc-members: - :inherited-members: diff --git a/doc/reference/reduction.rst b/doc/reference/reduction.rst index cb7ca16c..2ddbd38c 100644 --- a/doc/reference/reduction.rst +++ b/doc/reference/reduction.rst @@ -5,4 +5,3 @@ Reduction .. automodule:: pygsp.reduction :members: :undoc-members: - :inherited-members: From 928537065f45a5500417ddbb175b7bea636a8b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 22:56:18 +0200 Subject: [PATCH 223/392] autodoc: group properties together --- doc/conf.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 5b0130d3..24d3ba16 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,11 +1,15 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- import pygsp -extensions = ['sphinx.ext.autodoc', '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_member_order = 'groupwise' # alphabetical, groupwise, bysource extensions.append('matplotlib.sphinxext.plot_directive') plot_include_source = True From 3969e9a4756895e4ee9b01e258403da0ef7edc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 22:58:37 +0200 Subject: [PATCH 224/392] tutorials: update intro --- doc/tutorials/index.rst | 4 +- doc/tutorials/intro.rst | 212 +++++++++++++++++++++++++++++++--------- 2 files changed, 169 insertions(+), 47 deletions(-) diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index c87d057a..0564f255 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -2,8 +2,8 @@ Tutorials ========= -The following are some tutorials which show and explain how to use the toolbox -to solve some real problems. +The following are some tutorials which explain how to use the toolbox and show +how to use it to solve some problems. .. toctree:: :maxdepth: 1 diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 022aa678..2bd2f0b1 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -2,110 +2,232 @@ Introduction to the PyGSP ========================= -This tutorial shows basic operations of the toolbox. -To start open a python shell (IPython is recommended here) and import the pygsp. You would probably also import numpy as you will need it to create matrices and arrays. +This tutorial will show you the basic operations of the toolbox. After +installing the package with pip, start by opening a python shell, e.g. +a Jupyter notebook, and import the PyGSP. We will also need NumPy to create +matrices and arrays. .. plot:: :context: reset >>> import numpy as np + >>> import matplotlib.pyplot as plt >>> from pygsp import graphs, filters, plotting + +We then set default plotting parameters. We're using the ``matplotlib`` backend +to embed plots in this tutorial. The ``pyqtgraph`` backend is best suited for +interactive visualization. + +.. plot:: + :context: close-figs + >>> plotting.BACKEND = 'matplotlib' + >>> plt.rcParams['figure.figsize'] = (10, 5) + +Graphs +------ -The first step is to create a graph, there's a general class that can be used to generate graph from it's weight matrix. +Most likely, the first thing you would like to do is to create a graph_. In +this toolbox, a graph is encoded as an adjacency, or weight, matrix. That is +because it's the most efficient representation to deal with when using spectral +methods. As such, you can construct a graph from any adjacency matrix as +follows. + +.. _graph: https://en.wikipedia.org/wiki/Graph_(discrete_mathematics) .. plot:: :context: close-figs - >>> np.random.seed(42) # We will use a seed to make reproducible results - >>> W = np.random.rand(400, 400) + >>> rs = np.random.RandomState(42) # Reproducible results. + >>> W = rs.uniform(size=(30, 30)) # Full graph. + >>> W[W < 0.93] = 0 # Sparse graph. + >>> W = W + W.T # Symmetric graph. >>> G = graphs.Graph(W) + >>> print('{} nodes, {} edges'.format(G.N, G.Ne)) + 30 nodes, 122 edges -You have now a graph structure ready to be used everywhere in the box! Check the :mod:`pygsp.graphs` module to know more about the Graph class and it's subclasses. -You can also check the included methods for all graphs with the usual help function. +The :class:`pygsp.graphs.Graph` class we just instantiated is the base class +for all graph objects, which offers many methods and attributes. -For the next steps of the demo, we will be using the logo graph bundled with the toolbox : +Given, a graph object, we can test some properties. .. plot:: :context: close-figs - >>> G = graphs.Logo() + >>> G.is_connected() + True + >>> G.is_directed() + False -You can now plot the graph: +We can retrieve our weight matrix, which is stored in a sparse format. .. plot:: :context: close-figs - >>> G.plot() + >>> (G.W == W).all() + True + >>> type(G.W) + + +We can access the `graph Laplacian`_ -Looks good isn't it? Now we can start to analyse the graph. The next step to compute Graph Fourier Transform or exact graph filtering is to precompute the Fourier basis of the graph. This operation can be very long as it needs to to fully diagonalize the Laplacian. Happily it is not needed to filter signal on graphs. +.. _graph Laplacian: https://en.wikipedia.org/wiki/Laplacian_matrix .. plot:: :context: close-figs - >>> G.compute_fourier_basis() + >>> # The graph Laplacian (combinatorial by default). + >>> G.L.shape + (30, 30) -You can now access the eigenvalues of the fourier basis with G.e and the eigenvectors G.U, they look like sinuses on the graph. -Let's plot the second and third eigenvectors, as the first is constant. +We can also compute and get the graph Fourier basis (see below). .. plot:: :context: close-figs - >>> G.plot_signal(G.U[:, 1], vertex_size=50) - >>> G.plot_signal(G.U[:, 2], vertex_size=50) - -Let's discover basic filters operations, filters are usually defined in the spectral domain. + >>> G.compute_fourier_basis() + >>> G.U.shape + (30, 30) -Given the transfer function +Or the graph differential operator, useful to e.g. compute the gradient or +smoothness of a signal. -.. math:: \begin{equation*} g(x) =\frac{1}{1+\tau x} \end{equation*}, +.. plot:: + :context: close-figs -let's define a filter object: + >>> G.compute_differential_operator() + >>> G.D.shape # Not G.Ne / 2 because of self-loops. + (62, 30) + +.. note:: + Note that we called :meth:`pygsp.graphs.Graph.compute_fourier_basis` and + :meth:`pygsp.graphs.Graph.compute_differential_operator` before accessing + the Fourier basis :attr:`pygsp.graphs.Graph.U` and the differential + operator :attr:`pygsp.graphs.Graph.D`. Doing so is however not mandatory as + those matrices would have been computed when requested (lazy evaluation). + Omitting to call the *compute* functions does print a warning to tell you + that a potentially heavy computation is taking place under the hood (that's + also the reason those matrices are not computed when the graph object is + instantiated). It is thus encouraged to call them so that you are aware of + the involved computations. + +To be able to plot a graph, we need to embed its nodes in a 2D or 3D space. +While most included graph models define these coordinates, the graph we just +created do not. We only passed a weight matrix after all. Let's set some +coordinates with :meth:`pygsp.graphs.Graph.set_coordinates` and plot our graph. .. plot:: :context: close-figs - >>> tau = 1 - >>> def g(x): - ... return 1. / (1. + tau * x) - >>> F = filters.Filter(G, g) + >>> G.set_coordinates('ring2D') + >>> G.plot() -You can also put multiple functions in a list to define a filterbank! +While we created our first graph ourselves, many standard models of graphs are +implemented as subclasses of the Graph class and can be easily instantiated. +Check the :mod:`pygsp.graphs` module to get a list of them and learn more about +the Graph object. + +Fourier basis +------------- + +As in classical signal processing, the Fourier transform plays a central role +in graph signal processing. Getting the Fourier basis is however +computationally intensive as it needs to fully diagonalize the Laplacian. While +it can be used to filter signals on graphs, a better alternative is to use one +of the fast approximations (see :meth:`pygsp.filters.Filter.analysis`). Let's +compute it nonetheless to visualize the eigenvectors of the Laplacian. +Analogous to classical Fourier analysis, they look like sinuses on the graph. +Let's plot the second and third eigenvectors (the first is constant). Those are +graph signals, i.e. functions :math:`s: \mathcal{V} \rightarrow \mathbb{R}^d` +which assign a set of values (a vector in :math:`\mathbb{R}^d`) at every node +:math:`v \in \mathcal{V}` of the graph. .. plot:: :context: close-figs - >>> F.plot(plot_eigenvalues=True) + >>> G = graphs.Logo() + >>> G.compute_fourier_basis() + >>> + >>> fig, axes = plt.subplots(1, 2, figsize=(10, 3)) + >>> for i, ax in enumerate(axes): + ... G.plot_signal(G.U[:, i+1], vertex_size=30, ax=ax) + ... ax.set_title('Eigenvector {}'.format(i+2)) #doctest:+SKIP + ... ax.set_axis_off() + >>> fig.tight_layout() + +Filters +------- -Here's our low pass filter. +To filter signals on graphs, we need to define filters. They are represented in +the toolbox by the :class:`pygsp.filters.Filter` class. Filters are usually +defined in the spectral domain. Given the transfer function -To go with our new filter, let's create a nice signal on the logo by setting each letter to a certain value and then adding some random noise. +.. math:: g(x) = \frac{1}{1 + \tau x}, + +let's define and plot that low-pass filter: .. plot:: :context: close-figs - >>> f = np.zeros((G.N,)) - >>> f[G.info['idx_g']-1] = - 1 - >>> f[G.info['idx_s']-1] = 1 - >>> f[G.info['idx_p']-1] = -0.5 - >>> f += np.random.rand(G.N,) + >>> tau = 1 + >>> def g(x): + ... return 1. / (1. + tau * x) + >>> g = filters.Filter(G, g) + >>> + >>> fig, ax = plt.subplots() + >>> g.plot(plot_eigenvalues=True, ax=ax) + >>> ax.set_title('Filter frequency response') #doctest:+SKIP + +The filter is plotted along all the spectrum of the graph. The black crosses +are the eigenvalues of the Laplacian. They are the points where the continuous +filter will be evaluated to create a discrete filter. -The filter is plotted all along the spectrum of the graph, the cross at the bottom are the laplacian's eigenvalues. Those are the point where the continuous filter will be evaluated to create a discrete filter. -To apply it to a given signal, you only need to run: +.. note:: + You can put multiple functions in a list to define a `filter bank`_! -.. plot:: - :context: close-figs +.. _filter bank: https://en.wikipedia.org/wiki/Filter_bank - >>> f2 = F.analysis(f) +.. note:: + The :mod:`pygsp.filters` module implements various standard filters. -Finally here's the noisy signal and the denoised version right under. +Let's create a graph signal and add some random noise. .. plot:: :context: close-figs - >>> G.plot_signal(f, vertex_size=50) - >>> G.plot_signal(f2, vertex_size=50) + >>> # Graph signal: each letter gets a different value + additive noise. + >>> s = np.zeros(G.N) + >>> s[G.info['idx_g']-1] = -1 + >>> s[G.info['idx_s']-1] = 0 + >>> s[G.info['idx_p']-1] = 1 + >>> s += rs.uniform(-0.5, 0.5, size=G.N) + +We can now try to denoise that signal by filtering it with the above defined +low-pass filter. -So here are the basics for the PyGSP toolbox, please check the other tutorials or the reference guide for more. +.. plot:: + :context: close-figs -Enjoy the toolbox! + >>> s2 = g.analysis(s) + >>> + >>> fig, axes = plt.subplots(1, 2, figsize=(10, 3)) + >>> G.plot_signal(s, vertex_size=30, ax=axes[0]) + >>> axes[0].set_title('Noisy signal') #doctest:+SKIP + >>> axes[0].set_axis_off() + >>> G.plot_signal(s2, vertex_size=30, ax=axes[1]) + >>> axes[1].set_title('Cleaned signal') #doctest:+SKIP + >>> axes[1].set_axis_off() + >>> fig.tight_layout() + +While the noise is largely removed thanks to the filter, some energy is +diffused between the letters. This is the typical behavior of a low-pass +filter. + +So here are the basics for the PyGSP. Please check the other tutorials and the +reference guide for more. Enjoy! + +.. note:: + Please see the review article `The Emerging Field of Signal Processing on + Graphs: Extending High-Dimensional Data Analysis to Networks and Other + Irregular Domains `_ for an overview of + the methods this package leverages. From 0ddf2770d145f3f06a6f3b9516a14ef8edfd92e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 26 Aug 2017 22:59:18 +0200 Subject: [PATCH 225/392] tutorials: update wavelet --- doc/tutorials/wavelet.rst | 189 +++++++++++++++++++++++++------------- 1 file changed, 126 insertions(+), 63 deletions(-) diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index 1909e63e..80bbda2d 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -2,123 +2,186 @@ Introduction to spectral graph wavelets ======================================= -Description ------------ +This tutorial will show you how to easily construct a wavelet_ frame, a kind of +filter bank, and apply it to a signal. This tutorial will walk you into +computing the wavelet coefficients of a graph, visualizing filters in the +vertex domain, and using the wavelets to estimate the curvature of a 3D shape. -The wavelets are a special type of filterbank, in this demo we will show you how you can very easily construct a wavelet frame and apply it to a signal. +.. _wavelet: https://en.wikipedia.org/wiki/Wavelet -In this demo we will show you how to compute the wavelet coefficients of a graph and visualize them. -First let's import the toolbox, numpy and load a graph. +As usual, we first have to import some packages. .. plot:: :context: reset >>> import numpy as np - >>> from pygsp import graphs, filters, plotting + >>> import matplotlib.pyplot as plt + >>> from pygsp import graphs, filters, plotting, utils >>> plotting.BACKEND = 'matplotlib' - >>> G = graphs.Bunny() - -This graph is a nearest-neighbor graph of a pointcloud of the Stanford bunny. It will allow us to get interesting visual results using wavelets. -At this stage we could compute the full Fourier basis using +Then we can load a graph. The graph we'll use is a nearest-neighbor graph of a +point cloud of the Stanford bunny. It will allow us to get interesting visual +results using wavelets. .. plot:: :context: close-figs - >>> G.compute_fourier_basis() + >>> G = graphs.Bunny() -but this would take a lot of time, and can be avoided by using Chebychev polynomials approximations. +.. note:: + At this stage we could compute the Fourier basis using + :meth:`pygsp.graphs.Graph.compute_fourier_basis`, but this would take some + time, and can be avoided with a Chebychev polynomials approximation to + graph filtering. See the documentation of the + :meth:`pygsp.filters.Filter.analysis` filtering function and + :cite:`hammond2011wavelets` for details on how it is down. -Simple filtering ----------------- +Simple filtering: heat diffusion +-------------------------------- -Before tackling wavelets, we can see the effect of one filter localized on the graph. So we can first design a few heat kernel filters +Before tackling wavelets, let's observe the effect of a single filter on a +graph signal. We first design a few heat kernel filters, each with a different +scale. .. plot:: :context: close-figs - >>> taus = [1, 10, 25, 50] - >>> Hk = filters.Heat(G, taus, normalize=False) + >>> taus = [10, 25, 50] + >>> g = filters.Heat(G, taus) -Let's now create a signal as a Kronecker located on one vertex (e.g. the vertex 83) +Let's create a signal as a Kronecker delta located on one vertex, e.g. the +vertex 20. That signal is our heat source. .. plot:: :context: close-figs - >>> S = np.zeros(G.N) - >>> vertex_delta = 83 - >>> S[vertex_delta] = 1 - >>> Sf_vec = Hk.analysis(S) - >>> Sf = Sf_vec.reshape((Sf_vec.size//len(taus), len(taus)), order='F') + >>> s = np.zeros(G.N) + >>> delta = 20 + >>> s[delta] = 1 -Let's plot the signal: +We can now simulate heat diffusion by filtering our signal `s` with each of our +heat kernels. .. plot:: :context: close-figs - >>> G.plot_signal(Sf[:,0], vertex_size=20) - >>> G.plot_signal(Sf[:,1], vertex_size=20) - >>> G.plot_signal(Sf[:,2], vertex_size=20) - >>> G.plot_signal(Sf[:,3], vertex_size=20) + >>> s = g.analysis(s, method='chebyshev') + >>> s = utils.vec2mat(s, g.Nf) + +And finally plot the filtered signal showing heat diffusion at different +scales. + +.. plot:: + :context: close-figs + + >>> fig = plt.figure(figsize=(10, 3)) + >>> for i in range(g.Nf): + ... ax = fig.add_subplot(1, g.Nf, i+1, projection='3d') + ... G.plot_signal(s[:, i], vertex_size=20, colorbar=False, ax=ax) + ... title = r'Heat diffusion, $\tau={}$'.format(taus[i]) + ... ax.set_title(title) #doctest:+SKIP + ... ax.set_axis_off() + >>> fig.tight_layout() # doctest:+SKIP + +.. note:: + The :meth:`pygsp.filters.Filter.localize` method can be used to visualize a + filter in the vertex domain instead of doing it manually. Visualizing wavelets atoms -------------------------- -Let's now replace the Heat filter by a filter bank of wavelets. We can create a filter bank using one of the predefined filters such as :func:`pygsp.filters.MexicanHat`. +Let's now replace the Heat filter by a filter bank of wavelets. We can create a +filter bank using one of the predefined filters, such as +:class:`pygsp.filters.MexicanHat` to design a set of `Mexican hat wavelets`_. + +.. _Mexican hat wavelets: + https://en.wikipedia.org/wiki/Mexican_hat_wavelet .. plot:: :context: close-figs - >>> Nf = 6 - >>> Wk = filters.MexicanHat(G, Nf) + >>> g = filters.MexicanHat(G, Nf=6) # Nf = 6 filters in the filter bank. -We can now plot the filter bank spectrum : +Then plot the frequency response of those filters. .. plot:: :context: close-figs - >>> Wk.plot() + >>> fig, ax = plt.subplots(figsize=(10, 5)) + >>> g.plot(ax=ax) + >>> ax.set_title('Filter bank of mexican hat wavelets') # doctest:+SKIP + +.. note:: + We can see that the wavelet atoms are stacked on the low frequency part of + the spectrum. A better coverage could be obtained by adapting the filter + bank with :class:`pygsp.filters.WarpedTranslates` or by using another + filter bank like :class:`pygsp.filters.Itersine`. -As we can see, the wavelets atoms are stacked on the low frequency part of the spectrum. -If we want to get a better coverage of the graph spectrum, we could have used the WarpedTranslates filter bank. +We can visualize the filtering by one atom as we did with the heat kernel, by +filtering a Kronecker delta placed at one specific vertex. .. plot:: :context: close-figs - >>> S_vec = Wk.analysis(S) - >>> S = S_vec.reshape((S_vec.size//Nf, Nf), order='F') - >>> G.plot_signal(S[:, 0]) + >>> s = np.zeros((G.N * g.Nf, g.Nf)) + >>> s[delta] = 1 + >>> for i in range(g.Nf): + ... s[delta + i * G.N, i] = 1 + >>> s = g.synthesis(s) + >>> + >>> fig = plt.figure(figsize=(10, 7)) + >>> for i in range(4): + ... + ... # Clip the signal. + ... mu = np.mean(s[:, i]) + ... sigma = np.std(s[:, i]) + ... limits = [mu-4*sigma, mu+4*sigma] + ... + ... ax = fig.add_subplot(2, 2, i+1, projection='3d') + ... G.plot_signal(s[:, i], vertex_size=20, limits=limits, ax=ax) + ... ax.set_title('Wavelet {}'.format(i+1)) # doctest:+SKIP + ... ax.set_axis_off() + >>> fig.tight_layout() # doctest:+SKIP + +Curvature estimation +-------------------- + +As a last and more applied example, let us try to estimate the curvature of the +underlying 3D model by only using spectral filtering on the nearest-neighbor +graph formed by its point cloud. + +A simple way to accomplish that is to use the coordinates map :math:`[x, y, z]` +and filter it using the above defined wavelets. Doing so gives us a +3-dimensional signal +:math:`[g_i(L)x, g_i(L)y, g_i(L)z], \ i \in [0, \ldots, N_f]` +which describes variation along the 3 coordinates. + +.. plot:: + :context: close-figs + + >>> s = G.coords + >>> s = g.analysis(s) + >>> s = utils.vec2mat(s, g.Nf) -We can visualize the filtering by one atom the same way the did for the Heat kernel, by placing a Kronecker delta at one specific vertex. +The curvature is then estimated by taking the :math:`\ell_1` or :math:`\ell_2` +norm of the filtered signal. .. plot:: :context: close-figs - >>> S = np.zeros((G.N * Nf, Nf)) - >>> S[vertex_delta] = 1 - >>> for i in range(Nf): - ... S[vertex_delta + i * G.N, i] = 1 - >>> Sf = Wk.synthesis(S) - >>> - >>> G.plot_signal(Sf[:,0], vertex_size=20) - >>> G.plot_signal(Sf[:,1], vertex_size=20) - >>> G.plot_signal(Sf[:,2], vertex_size=20) - >>> G.plot_signal(Sf[:,3], vertex_size=20) + >>> s = np.linalg.norm(s, ord=2, axis=2) + +Let's finally plot the result to observe that we indeed have a measure of the +curvature at different scales. .. plot:: :context: close-figs - >>> G = graphs.Bunny() - >>> Wk = filters.MexicanHat(G, Nf) - >>> s_map = G.coords - >>> - >>> s_map_out = Wk.analysis(s_map) - >>> s_map_out = np.reshape(s_map_out, (G.N, Nf, 3)) - >>> - >>> d = s_map_out[:, :, 0]**2 + s_map_out[:, :, 1]**2 + s_map_out[:, :, 2]**2 - >>> d = np.sqrt(d) - >>> - >>> G.plot_signal(d[:, 1], vertex_size=20) - >>> G.plot_signal(d[:, 2], vertex_size=20) - >>> G.plot_signal(d[:, 3], vertex_size=20) - >>> G.plot_signal(d[:, 4], vertex_size=20) + >>> fig = plt.figure(figsize=(10, 7)) + >>> for i in range(4): + ... ax = fig.add_subplot(2, 2, i+1, projection='3d') + ... G.plot_signal(s[:, i], vertex_size=20, ax=ax) + ... title = 'Curvature estimation (scale {})'.format(i+1) + ... ax.set_title(title) # doctest:+SKIP + ... ax.set_axis_off() + >>> fig.tight_layout() # doctest:+SKIP From 080a81f3e61ab88b947effe2bb8362adbb08796f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 19:18:20 +0200 Subject: [PATCH 226/392] plotting: plot signals on 1D graph embeddings --- pygsp/plotting.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 863ac1e9..4cf5f4d4 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -53,12 +53,13 @@ def _plt_handle_figure(plot): def inner(obj, *args, **kwargs): # Create a figure and an axis if none were passed. - if not 'ax' in kwargs.keys(): + if 'ax' not in kwargs.keys(): fig = plt.figure() global _plt_figures _plt_figures.append(fig) - if hasattr(obj, 'coords') and obj.coords.shape[1] == 3: + if (hasattr(obj, 'coords') and obj.coords.ndim == 2 and + obj.coords.shape[1] == 3): ax = fig.add_subplot(111, projection='3d') else: ax = fig.add_subplot(111) @@ -571,7 +572,9 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, raise NotImplementedError else: - if G.coords.shape[1] == 2: + if G.coords.ndim == 1: + pass + elif G.coords.shape[1] == 2: x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), np.expand_dims(G.coords[kj, 0], axis=0))) y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), @@ -599,15 +602,20 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, x3 = x2[i:i + 2] y3 = y2[i:i + 2] z3 = z2[i:i + 2] - ax.plot(x3, y3, z3, color='grey', marker='o', - markerfacecolor='blue', zorder=1) + ax.plot(x3, y3, z3, linewidth=G.plotting['edge_width'], + color=G.plotting['edge_color'], + linestyle=G.plotting['edge_style'], + zorder=1) # Plot signal - if G.coords.shape[1] == 2: + if G.coords.ndim == 1: + ax.plot(G.coords, signal) + ax.set_ylim(limits) + elif G.coords.shape[1] == 2: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], s=vertex_size, c=signal, zorder=2, vmin=limits[0], vmax=limits[1]) - if G.coords.shape[1] == 3: + elif G.coords.shape[1] == 3: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], s=vertex_size, c=signal, zorder=2, vmin=limits[0], vmax=limits[1]) @@ -618,7 +626,7 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, except KeyError: pass - if colorbar: + if G.coords.ndim != 1 and colorbar: plt.colorbar(sc, ax=ax) From 76d64a4ae78fa2085dabb67e503af37cf0f5e043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 19:20:02 +0200 Subject: [PATCH 227/392] set_coordinates: 1D line useful for e.g. path or ring graph --- pygsp/graphs/graph.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 8748e668..8eb89135 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -278,7 +278,7 @@ def set_coordinates(self, kind='spring', **kwargs): 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, spring. Default is 'spring'. + ring2D, line1D, spring. Default is 'spring'. kwargs : dict Additional parameters to be passed to the Fruchterman-Reingold force-directed algorithm when kind is spring. @@ -293,13 +293,21 @@ def set_coordinates(self, kind='spring', **kwargs): """ if not isinstance(kind, str): - coords = np.asarray(kind) - check_dim = (2 <= coords.shape[1] <= 3) - if coords.ndim != 2 or coords.shape[0] != self.N or not check_dim: - raise ValueError('Expecting coordinates to be of size Nx2 or ' - 'Nx3.') + 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) From d0e91ba506ff4994fc3c60e141959b9e4446943e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 19:22:50 +0200 Subject: [PATCH 228/392] coordinates for path, ring, and randomring --- pygsp/graphs/path.py | 19 +++++++++---------- pygsp/graphs/randomring.py | 6 +++++- pygsp/graphs/ring.py | 10 ++++++---- pygsp/tests/test_plotting.py | 3 --- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index f38c60ae..75ed3df9 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -26,16 +26,15 @@ class Path(Graph): """ - def __init__(self, N=16): + def __init__(self, N=16, **kwargs): - inds_i = np.concatenate((np.arange(N - 1), np.arange(1, N))) - inds_j = np.concatenate((np.arange(1, N), np.arange(N - 1))) + inds_i = np.concatenate((np.arange(0, N-1), np.arange(1, N))) + inds_j = np.concatenate((np.arange(1, N), np.arange(0, N-1))) + weights = np.ones(2 * (N-1)) + W = sparse.csc_matrix((weights, (inds_i, inds_j)), shape=(N, N)) + plotting = {"limits": np.array([-1, N, -1, 1])} - W = sparse.csc_matrix((np.ones((2*(N - 1))), (inds_i, inds_j)), - shape=(N, N)) - coords = np.concatenate(((np.arange(N) + 1)[:, np.newaxis], - np.zeros((N, 1))), - axis=1) - plotting = {"limits": np.array([0, N + 1, -1, 1])} + super(Path, self).__init__(W=W, gtype='path', + plotting=plotting, **kwargs) - super(Path, self).__init__(W=W, coords=coords, gtype='path') + self.set_coordinates('line2D') diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index b70070f3..4c832e91 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -37,6 +37,10 @@ def __init__(self, N=64): W[N - 1, 0] = weightend W = W + W.T + angle = position * 2 * np.pi + coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} - super(RandomRing, self).__init__(W=W, gtype='random-ring', plotting=plotting) + super(RandomRing, self).__init__(W=W, gtype='random-ring', + coords=coords, plotting=plotting, + **kwargs) diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 7324925d..7e02fcd8 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -13,9 +13,9 @@ class Ring(Graph): Parameters ---------- N : int - Number of vertices (default is 64) + Number of vertices. k : int - Number of neighbors in each directions (default is 1) + Number of neighbors in each direction. Examples -------- @@ -29,7 +29,6 @@ def __init__(self, N=64, k=1, **kwargs): if 2*k > N: raise ValueError('Too many neighbors requested.') - # Create weighted adjacency matrix if 2*k == N: num_edges = N * (k - 1) + k else: @@ -57,4 +56,7 @@ def __init__(self, N=64, k=1, **kwargs): gtype = 'ring' if k == 1 else 'k-ring' self.k = k - super(Ring, self).__init__(W=W, gtype=gtype, plotting=plotting, **kwargs) + super(Ring, self).__init__(W=W, gtype=gtype, plotting=plotting, + **kwargs) + + self.set_coordinates('ring2D') diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index efa675df..e2a40f65 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -38,8 +38,6 @@ def test_plot_graphs(self): 'ErdosRenyi', 'FullConnected', 'RandomRegular', - 'RandomRing', - 'Ring', # TODO: should have! 'StochasticBlockModel', } @@ -90,7 +88,6 @@ def test_plot_graphs(self): G.plot_signal(signal, backend='matplotlib') plotting.close_all() - def test_save(self): G = graphs.Logo() name = 'test_plot' From 7f56776d4df281e778ced5bccedb299571b8e51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 19:24:50 +0200 Subject: [PATCH 229/392] randomring: reproducible graph with seed --- pygsp/graphs/randomring.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 4c832e91..5f287c89 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -13,7 +13,9 @@ class RandomRing(Graph): Parameters ---------- N : int - Number of vertices (default = 64) + Number of vertices. + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -22,19 +24,20 @@ class RandomRing(Graph): """ - def __init__(self, N=64): + def __init__(self, N=64, seed=None, **kwargs): - position = np.sort(np.random.rand(N), axis=0) + rs = np.random.RandomState(seed) + position = np.sort(rs.uniform(size=N), axis=0) - weight = N*np.diff(position) - weightend = N*(1 + position[0] - position[-1]) + weight = N * np.diff(position) + weight_end = N * (1 + position[0] - position[-1]) + inds_i = np.arange(0, N-1) inds_j = np.arange(1, N) - inds_i = np.arange(N - 1) W = sparse.csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) W = W.tolil() - W[N - 1, 0] = weightend + W[N-1, 0] = weight_end W = W + W.T angle = position * 2 * np.pi From 6729ed2781be7b4cff3d4759b9bf654795d9a796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 19:26:14 +0200 Subject: [PATCH 230/392] plotting: much better handling of plotting parameters --- pygsp/graphs/graph.py | 7 +- pygsp/graphs/nngraphs/bunny.py | 2 - pygsp/plotting.py | 231 ++++++++++++++++----------------- 3 files changed, 115 insertions(+), 125 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 8eb89135..e241f03a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -103,8 +103,11 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', if not self.is_connected(): self.logger.warning('Graph is not connected!') - self.plotting = {'vertex_size': 10, 'edge_width': 1, - 'edge_style': '-', 'vertex_color': 'b'} + self.plotting = {'vertex_size': 100, + 'vertex_color': (0.12, 0.47, 0.71, 1), + 'edge_color': (0.5, 0.5, 0.5, 1), + 'edge_width': 1, + 'edge_style': '-'} self.plotting.update(plotting) def check_weights(self): diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 41e2124e..5efbb5ca 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -24,8 +24,6 @@ def __init__(self, **kwargs): data = utils.loadmat('pointclouds/bunny') plotting = {'vertex_size': 10, - 'vertex_color': (1, 1, 1, 1), - 'edge_color': (.5, .5, .5, 1), 'elevation': -89, 'azimuth': 94, 'distance': 7} diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 4cf5f4d4..986f3a1c 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -19,6 +19,8 @@ """ +from __future__ import division + import numpy as np try: @@ -163,10 +165,14 @@ def plot_graph(G, backend=None, **kwargs): ---------- G : Graph Graph to plot. - show_edges : boolean - Set to False to only draw the vertices (default G.Ne < 10000). + show_edges : bool + True to draw edges, false to only draw vertices. + Default True if less than 10,000 edges to draw. + Note that drawing a large number of edges might be particularly slow. backend: {'matplotlib', 'pyqtgraph'} Defines the drawing backend to use. Defaults to :data:`BACKEND`. + vertex_size : float + Size of circle representing each node. plot_name : str name of the plot save_as : str @@ -186,9 +192,16 @@ def plot_graph(G, backend=None, **kwargs): if not hasattr(G, 'coords'): raise AttributeError('Graph has no coordinate set. ' 'Please run G.set_coordinates() first.') - if G.coords.shape[1] not in [2, 3]: + if (G.coords.ndim != 2) or (G.coords.shape[1] not in [2, 3]): raise AttributeError('Coordinates should be in 2D or 3D space.') + kwargs['show_edges'] = kwargs.pop('show_edges', G.Ne < 10e3) + + default = G.plotting['vertex_size'] + kwargs['vertex_size'] = kwargs.pop('vertex_size', default) + + kwargs['plot_name'] = kwargs.pop('plot_name', G.gtype) + if backend is None: backend = BACKEND @@ -201,54 +214,40 @@ def plot_graph(G, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_graph(G, show_edges=None, plot_name='', ax=None): +def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph - if not plot_name: - plot_name = u"Plot of {}".format(G.gtype) - - if show_edges is None: - show_edges = G.Ne < 10000 - - try: - vertex_size = G.plotting['vertex_size'] - except KeyError: - vertex_size = 100 - - try: - edge_color = G.plotting['edge_color'] - except KeyError: - edge_color = [0.5, 0.5, 0.5] - if show_edges: ki, kj = np.nonzero(G.A) + if G.is_directed(): raise NotImplementedError + else: if G.coords.shape[1] == 2: - ki, kj = np.nonzero(G.A) + # TODO: use np.stack x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), np.expand_dims(G.coords[kj, 0], axis=0))) y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), np.expand_dims(G.coords[kj, 1], axis=0))) - if isinstance(G.plotting['vertex_color'], list): - ax.plot(x, y, linewidth=G.plotting['edge_width'], - color=edge_color, - linestyle=G.plotting['edge_style'], - marker='', zorder=1) - - ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', - s=vertex_size, - c=G.plotting['vertex_color'], zorder=2) - else: - ax.plot(x, y, linewidth=G.plotting['edge_width'], - color=edge_color, - linestyle=G.plotting['edge_style'], - marker='o', markersize=vertex_size, - markerfacecolor=G.plotting['vertex_color']) +# if isinstance(G.plotting['vertex_color'], list): +# ax.plot(x, y, linewidth=G.plotting['edge_width'], +# color=G.plotting['edge_color'], +# linestyle=G.plotting['edge_style'], +# marker='', zorder=1) +# +# ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', +# s=vertex_size, +# c=G.plotting['vertex_color'], zorder=2) +# else: + ax.plot(x, y, linewidth=G.plotting['edge_width'], + color=G.plotting['edge_color'], + linestyle=G.plotting['edge_style'], + marker='o', markersize=vertex_size/10, + markerfacecolor=G.plotting['vertex_color']) if G.coords.shape[1] == 3: # Very dirty way to display a 3d graph @@ -273,9 +272,9 @@ def _plt_plot_graph(G, show_edges=None, plot_name='', ax=None): y3 = y2[i:i + 2] z3 = z2[i:i + 2] ax.plot(x3, y3, z3, linewidth=G.plotting['edge_width'], - color=edge_color, + color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], - marker='o', markersize=vertex_size, + marker='o', markersize=vertex_size/10, markerfacecolor=G.plotting['vertex_color']) else: # TODO: is ax.plot(G.coords[:, 0], G.coords[:, 1], 'bo') faster? @@ -299,18 +298,10 @@ def _plt_plot_graph(G, show_edges=None, plot_name='', ax=None): # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() -def _qtg_plot_graph(G, show_edges=None, plot_name=''): +def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): # TODO handling when G is a list of graphs - if show_edges is None: - show_edges = G.Ne < 10000 - - try: - edge_color = G.plotting['edge_color'] - except KeyError: - edge_color = [0.5, 0.5, 0.5] - ki, kj = np.nonzero(G.A) if G.is_directed(): raise NotImplementedError @@ -320,27 +311,30 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): np.expand_dims(kj, axis=1)), axis=1) window = qtg.GraphicsWindow() - window.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) + window.setWindowTitle(plot_name) view = window.addViewBox() view.setAspectLocked() - extra_args = {} - if isinstance(G.plotting['vertex_color'], list): - extra_args['symbolPen'] = [qtg.mkPen(v_col) for v_col in G.plotting['vertex_color']] - extra_args['brush'] = [qtg.mkBrush(v_col) for v_col in G.plotting['vertex_color']] - elif isinstance(G.plotting['vertex_color'], int): - extra_args['symbolPen'] = G.plotting['vertex_color'] - extra_args['brush'] = G.plotting['vertex_color'] +# extra_args = {} +# if isinstance(G.plotting['vertex_color'], list): +# extra_args['symbolPen'] = [qtg.mkPen(v_col) for v_col in G.plotting['vertex_color']] +# extra_args['brush'] = [qtg.mkBrush(v_col) for v_col in G.plotting['vertex_color']] +# elif isinstance(G.plotting['vertex_color'], int): +# extra_args['symbolPen'] = G.plotting['vertex_color'] +# extra_args['brush'] = G.plotting['vertex_color'] # Define syntactic sugar mapping keywords for the display options - for plot_args, qtg_args in [('vertex_size', 'size'), ('vertex_mask', 'mask'), ('edge_color', 'pen'), ('symbolPen', 'symbolPen')]: - if plot_args in G.plotting: - extra_args[qtg_args] = G.plotting[plot_args] +# for plot_args, qtg_args in [('vertex_mask', 'mask'), ('edge_color', 'pen'), ('symbolPen', 'symbolPen')]: +# if plot_args in G.plotting: +# extra_args[qtg_args] = G.plotting[plot_args] - if not show_edges: - extra_args['pen'] = None + if show_edges: + pen = tuple(np.array(G.plotting['edge_color']) * 255) + else: + pen = None - g = qtg.GraphItem(pos=G.coords, adj=adj, **extra_args) + g = qtg.GraphItem(pos=G.coords, adj=adj, pen=pen, + size=vertex_size/10) view.addItem(g) global _qtg_windows @@ -353,7 +347,7 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() - widget.setWindowTitle(G.plotting['plot_name'] if 'plot_name' in G.plotting else plot_name or G.gtype) + widget.setWindowTitle(plot_name) # Very dirty way to display a 3d graph x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), @@ -377,29 +371,13 @@ def _qtg_plot_graph(G, show_edges=None, plot_name=''): np.expand_dims(y2, axis=1), np.expand_dims(z2, axis=1)), axis=1) - extra_args = {'color': (0, 0, 1, 1)} - if 'vertex_color' in G.plotting: - if isinstance(G.plotting['vertex_color'], list): - extra_args['color'] = np.array([qtg.glColor(qtg.mkPen(v_col).color()) for v_col in G.plotting['vertex_color']]) - elif isinstance(G.plotting['vertex_color'], int): - extra_args['color'] = qtg.glColor(qtg.mkPen(G.plotting['vertex_color']).color()) - else: - extra_args['color'] = G.plotting['vertex_color'] - - # Define syntaxic sugar mapping keywords for the display options - for plot_args, qtg_args in [('vertex_size', 'size')]: - if plot_args in G.plotting: - G.plotting[qtg_args] = G.plotting.pop(plot_args) - - for qtg_args in ['size']: - if qtg_args in G.plotting: - extra_args[qtg_args] = G.plotting[qtg_args] - if show_edges: - g = gl.GLLinePlotItem(pos=pts, mode='lines', color=edge_color) + g = gl.GLLinePlotItem(pos=pts, mode='lines', + color=G.plotting['edge_color']) widget.addItem(g) - gp = gl.GLScatterPlotItem(pos=G.coords, **extra_args) + gp = gl.GLScatterPlotItem(pos=G.coords, size=vertex_size/3, + color=G.plotting['vertex_color']) widget.addItem(gp) global _qtg_widgets @@ -456,8 +434,6 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, plot_eigenvalues = hasattr(G, '_e') if show_sum is None: show_sum = filters.Nf > 1 - if plot_name is None: - plot_name = u"Filter plot of {}".format(G.gtype) lambdas = np.linspace(0, G.lmax, npoints) @@ -497,12 +473,14 @@ def plot_signal(G, signal, backend=None, **kwargs): Graph to plot a signal on top. signal : array of int Signal to plot. Signal length should be equal to the number of nodes. - show_edges : boolean - Set to False to only draw the vertices (default G.Ne < 10000). + show_edges : bool + True to draw edges, false to only draw vertices. + Default True if less than 10,000 edges to draw. + Note that drawing a large number of edges might be particularly slow. cp : list of int NOT IMPLEMENTED. Camera position when plotting a 3D graph. - vertex_size : int - Size of circle representing each signal component. + vertex_size : float + Size of circle representing each node. vertex_highlight : list of boolean NOT IMPLEMENTED. Vector of indices for vertices to be highlighted. colorbar : bool @@ -540,6 +518,30 @@ def plot_signal(G, signal, backend=None, **kwargs): if not hasattr(G, 'coords'): raise AttributeError('Graph has no coordinate set. ' 'Please run G.set_coordinates() first.') + check_2d_3d = (G.coords.ndim != 2) or (G.coords.shape[1] not in [2, 3]) + if G.coords.ndim != 1 and check_2d_3d: + raise AttributeError('Coordinates should be in 1D, 2D or 3D space.') + + signal = signal.squeeze() + if G.coords.ndim == 2 and signal.ndim != 1: + raise ValueError('Can plot only one signal (not {}) with {}D ' + 'coordinates.'.format(signal.shape[1], + G.coords.shape[1])) + if signal.shape[0] != G.N: + raise ValueError('Signal length is {}, should be ' + 'G.N = {}.'.format(signal.shape[0], G.N)) + if np.sum(np.abs(signal.imag)) > 1e-10: + raise ValueError("Can't display complex signal.") + + kwargs['show_edges'] = kwargs.pop('show_edges', G.Ne < 10e3) + + default = G.plotting['vertex_size'] + kwargs['vertex_size'] = kwargs.pop('vertex_size', default) + + kwargs['plot_name'] = kwargs.pop('plot_name', G.gtype) + + limits = [1.05*signal.min(), 1.05*signal.max()] + kwargs['limits'] = kwargs.pop('limits', limits) if backend is None: backend = BACKEND @@ -553,17 +555,8 @@ def plot_signal(G, signal, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, - colorbar=True, plot_name=None, ax=None): - - if np.sum(np.abs(signal.imag)) > 1e-10: - raise ValueError("Can't display complex signal.") - if show_edges is None: - show_edges = G.Ne < 10000 - if plot_name is None: - plot_name = "Signal plot of " + G.gtype - if limits is None: - limits = [signal.min(), signal.max()] +def _plt_plot_signal(G, signal, show_edges, limits, plot_name, ax, + vertex_size, colorbar=True): if show_edges: ki, kj = np.nonzero(G.A) @@ -579,8 +572,11 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, np.expand_dims(G.coords[kj, 0], axis=0))) y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), np.expand_dims(G.coords[kj, 1], axis=0))) - ax.plot(x, y, color='grey', zorder=1) - if G.coords.shape[1] == 3: + ax.plot(x, y, linewidth=G.plotting['edge_width'], + color=G.plotting['edge_color'], + linestyle=G.plotting['edge_style'], + zorder=1) + elif G.coords.shape[1] == 3: # Very dirty way to display 3D graph edges x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), np.expand_dims(G.coords[kj, 0], axis=0))) @@ -630,17 +626,10 @@ def _plt_plot_signal(G, signal, show_edges=None, vertex_size=100, limits=None, plt.colorbar(sc, ax=ax) -def _qtg_plot_signal(G, signal, show_edges=None, - vertex_size=15, plot_name=None): - - if np.sum(np.abs(signal.imag)) > 1e-10: - raise ValueError("Can't display complex signal.") - - if show_edges is None: - show_edges = G.Ne < 10000 +def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): if G.coords.shape[1] == 2: - window = qtg.GraphicsWindow(plot_name or G.gtype) + window = qtg.GraphicsWindow(plot_name) view = window.addViewBox() elif G.coords.shape[1] == 3: if not QtGui.QApplication.instance(): @@ -649,7 +638,7 @@ def _qtg_plot_signal(G, signal, show_edges=None, widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() - widget.setWindowTitle(plot_name or G.gtype) + widget.setWindowTitle(plot_name) # Plot edges if show_edges: @@ -661,8 +650,9 @@ def _qtg_plot_signal(G, signal, show_edges=None, adj = np.concatenate((np.expand_dims(ki, axis=1), np.expand_dims(kj, axis=1)), axis=1) + pen = tuple(np.array(G.plotting['edge_color']) * 255) g = qtg.GraphItem(pos=G.coords, adj=adj, symbolBrush=None, - symbolPen=None) + symbolPen=None, pen=pen) view.addItem(g) if G.coords.shape[1] == 3: @@ -688,12 +678,9 @@ def _qtg_plot_signal(G, signal, show_edges=None, np.expand_dims(y2, axis=1), np.expand_dims(z2, axis=1)), axis=1) - g = gl.GLLinePlotItem(pos=pts, mode='lines') - - gp = gl.GLScatterPlotItem(pos=G.coords, color=(1., 0., 0., 1)) - + g = gl.GLLinePlotItem(pos=pts, mode='lines', + color=G.plotting['edge_color']) widget.addItem(g) - widget.addItem(gp) # Plot signal on top pos = [1, 8, 24, 40, 56, 64] @@ -708,12 +695,14 @@ def _qtg_plot_signal(G, signal, show_edges=None, if G.coords.shape[1] == 2: gp = qtg.ScatterPlotItem(G.coords[:, 0], - G.coords[:, 1], - size=vertex_size, - brush=cmap.map(normalized_signal, 'qcolor')) + G.coords[:, 1], + size=vertex_size/10, + brush=cmap.map(normalized_signal, 'qcolor')) view.addItem(gp) if G.coords.shape[1] == 3: - gp = gl.GLScatterPlotItem(pos=G.coords, size=vertex_size, color=signal) + gp = gl.GLScatterPlotItem(pos=G.coords, + size=vertex_size/3, + color=signal) widget.addItem(gp) # Multiple windows handling From 6453496317565f1dae9a3a78688db0466d489542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:16:53 +0200 Subject: [PATCH 231/392] compute_fourier_basis: don't allow to choose ordering --- pygsp/graphs/fourier.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index b5e90a5a..8b52212c 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -31,7 +31,8 @@ def U(self): @property def e(self): r""" - Graph frequencies, i.e. the eigenvalues of the Laplacian. + The eigenvalues of the Laplacian. + Their square root are the graph frequencies. Is computed by :func:`compute_fourier_basis`. """ return self._check_fourier_properties('e', 'eigenvalues vector') @@ -44,8 +45,7 @@ def mu(self): """ return self._check_fourier_properties('mu', 'Fourier basis coherence') - def compute_fourier_basis(self, smallest_first=True, recompute=False, - **kwargs): + def compute_fourier_basis(self, recompute=False): r""" Compute the Fourier basis of the graph. @@ -54,9 +54,6 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, Parameters ---------- - smallest_first: bool - Define the order of the eigenvalues. - Default is smallest first (True). recompute: bool Force to recompute the Fourier basis if already existing. @@ -78,7 +75,7 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, References ---------- - See :cite:`chung1997spectral` + See :cite:`chung1997spectral`. Examples -------- @@ -103,15 +100,10 @@ def compute_fourier_basis(self, smallest_first=True, recompute=False, self.logger.warning("Performing full eigendecomposition of a " "large matrix may take some time.") - if not hasattr(self, 'L'): - raise AttributeError("Graph Laplacian is missing.") - # TODO: np.linalg.{svd,eigh}, sparse.linalg.{svds,eigsh} eigenvectors, eigenvalues, _ = scipy.linalg.svd(self.L.todense()) inds = np.argsort(eigenvalues) - if not smallest_first: - inds = inds[::-1] self._e = np.sort(eigenvalues) self._lmax = np.max(self._e) From 99483e3ca1578397e421a5ea8a5f96e937e59af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:18:53 +0200 Subject: [PATCH 232/392] estimate_lmax: use Lanczos instead of Arnoldi Because the Laplacian is symmetric. The method returns real eigenvalues (no imaginary part). --- pygsp/graphs/graph.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e241f03a..2f7a557f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -652,9 +652,9 @@ def lmax(self): def estimate_lmax(self, recompute=False): r""" - Estimate the largest eigenvalue. + Estimate the largest eigenvalue of the graph Laplacian. - The result is cached and accessible by the :py:attr:`lmax` property. + The result is cached and accessible by the :attr:`lmax` property. Exact value given by the eigendecomposition of the Laplacian, see :func:`compute_fourier_basis`. That estimation is much faster than the @@ -665,6 +665,17 @@ def estimate_lmax(self, recompute=False): recompute : boolean Force to recompute the largest eigenvalue. Default is false. + Notes + ----- + Runs the implicitly restarted Lanczos method with a large tolerance, + then increases the calculated largest eigenvalue by 1 percent. For much + of the PyGSP machinery, we need to approximate wavelet kernels on an + interval that contains the spectrum of L. The only cost of using a + larger interval is that the polynomial approximation over the larger + interval may be a slightly worse approximation on the actual spectrum. + As this is a very mild effect, it is not necessary to obtain very tight + bounds on the spectrum of L. + Examples -------- >>> from pygsp import graphs @@ -682,16 +693,24 @@ def estimate_lmax(self, recompute=False): return try: - # For robustness purposes, increase the error by 1 percent - lmax = 1.01 * \ - sparse.linalg.eigs(self.L, k=1, tol=5e-3, ncv=10)[0][0] + lmax = sparse.linalg.eigsh(self.L, k=1, tol=5e-3, + ncv=min(self.N, 10), + return_eigenvectors=False) + lmax = lmax[0] + lmax *= 1.01 # Increase by 1 percent to be robust to errors. except sparse.linalg.ArpackNoConvergence: - self.logger.warning('Cannot use default method.') - lmax = 2. * np.max(self.d) + self.logger.warning('Lanczos method did not converge. ' + 'Using an alternative method.') + if self.lap_type == 'normalized': + lmax = 2 # Spectrum is bounded by [0, 2]. + elif self.lap_type == 'combinatorial': + lmax = 2 * np.max(self.d) + else: + raise ValueError('Unknown Laplacian type ' + '{}'.format(self.lap_type)) - lmax = np.real(lmax) - self._lmax = lmax.sum() + self._lmax = lmax def get_edge_list(self): r""" From 070aaa7c3506b995a99e7377b0cdb3732621a713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:36:42 +0200 Subject: [PATCH 233/392] Mexican hat filter: better implementation and documentation --- pygsp/filters/mexicanhat.py | 64 +++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index bf5e35d0..6c8597dd 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from pygsp import utils @@ -8,54 +10,68 @@ class MexicanHat(Filter): r""" - Mexican hat filterbank + Design the Mexican hat wavelet filter bank. + + The Mexican hat wavelet is the second oder derivative of a Gaussian. Since + we express the filter in the Fourier domain, we find: + + .. math:: \hat{g}_b(x) = x * \exp(-x) + + for the band-pass filter. Note that in our convention the eigenvalues of + the Laplacian are equivalent to the square of graph frequencies, + i.e. :math:`x = \lambda^2`. + + The low-pass filter is given by + + .. math: \hat{g}_l(x) = \exp(-x^4). Parameters ---------- G : graph Nf : int - Number of filters from 0 to lmax (default = 6) + Number of filters to cover the interval [0, lmax]. lpfactor : int - Low-pass factor lmin=lmax/lpfactor will be used to determine scales, - the scaling function will be created to fill the lowpass gap. - (default = 20) - scales : ndarray - Vector of scales to be used. + 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 to be used. By default, initialized with :func:`pygsp.utils.compute_log_scales`. normalize : bool - Wether to normalize the wavelet by the factor/sqrt(t). - (default = False) + Whether to normalize the wavelet by the factor ``sqrt(scales)``. Examples -------- >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> F = filters.MexicanHat(G) + >>> g = filters.MexicanHat(G) """ def __init__(self, G, Nf=6, lpfactor=20, scales=None, normalize=False, **kwargs): - G.lmin = G.lmax / lpfactor + lmin = G.lmax / lpfactor if scales is None: - self.scales = utils.compute_log_scales(G.lmin, G.lmax, Nf - 1) - else: - self.scales = scales + scales = utils.compute_log_scales(lmin, G.lmax, Nf-1) + + if len(scales) != Nf - 1: + raise ValueError('len(scales) should be Nf-1.') - gb = lambda x: x * np.exp(-x) - gl = lambda x: np.exp(-np.power(x, 4)) + def band_pass(x): + return x * np.exp(-x) - lminfac = .4 * G.lmin + def low_pass(x): + return np.exp(-x**4) - g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] + kernels = [lambda x: 1.2 * np.exp(-1) * low_pass(x / 0.4 / lmin)] for i in range(Nf - 1): - if normalize: - g.append(lambda x, ind=i: np.sqrt(self.scales[ind]) * - gb(self.scales[ind] * x)) - else: - g.append(lambda x, ind=i: gb(self.scales[ind] * x)) - super(MexicanHat, self).__init__(G, g, **kwargs) + def kernel(x, i=i): + norm = np.sqrt(scales[i]) if normalize else 1 + return norm * band_pass(scales[i] * x) + + kernels.append(kernel) + + super(MexicanHat, self).__init__(G, kernels, **kwargs) From f2610bd1e12eeaea6620a195a9a36d16e1a3404f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:41:11 +0200 Subject: [PATCH 234/392] heat filter: normalize outside of kernel --- pygsp/filters/heat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index f6386172..3fbdbbe6 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -56,12 +56,12 @@ def __init__(self, G, tau=10, normalize=False, **kwargs): except TypeError: tau = [tau] - def kernel(x, t, norm=1): - return np.exp(-t * x / G.lmax) / norm + def kernel(x, t): + return np.exp(-t * x / G.lmax) g = [] for t in tau: norm = np.linalg.norm(kernel(G.e, t)) if normalize else 1 - g.append(lambda x, t=t, norm=norm: kernel(x, t, norm)) + g.append(lambda x, t=t, norm=norm: kernel(x, t) / norm) super(Heat, self).__init__(G, g, **kwargs) From cac9a8cb66ed95581006f77e4604fb317dfe15e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:41:49 +0200 Subject: [PATCH 235/392] simpletf: clean and document --- pygsp/filters/simpletf.py | 41 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletf.py index 7f594c19..9d98f889 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletf.py @@ -11,16 +11,20 @@ class SimpleTf(Filter): r""" - SimpleTf filterbank + Design a simple tight frame filter bank. + + These filters have been designed to be a simple tight frame wavelet filter + bank. The kernel is similar to Meyer, but simpler. The function is + essentially :math:`\sin^2(x)` in ascending part and :math:`\cos^2` in + descending part. Parameters ---------- G : graph Nf : int - Number of filters from 0 to lmax (default = 6) - t : ndarray - Vector of scale to be used (Initialized by default at the value - of the log scale) + Number of filters to cover the interval [0, lmax]. + scales : array-like + Scales to be used. Defaults to log scale. Examples -------- @@ -30,20 +34,24 @@ class SimpleTf(Filter): """ - def __init__(self, G, Nf=6, t=None, **kwargs): + def __init__(self, G, Nf=6, scales=None, **kwargs): - def kernel_simple_tf(x, kerneltype): + def kernel(x, kerneltype): r""" - Evaluates 'simple' tight-frame kernel + Evaluates 'simple' tight-frame kernel. + + * simple tf wavelet kernel: supported on [1/4, 1] + * simple tf scaling function kernel: supported on [0, 1/2] Parameters ---------- x : ndarray - Array if independant variable values + Array of independent variable values kerneltype : str Can be either 'sf' or 'wavelet' - Returns: + Returns + ------- r : ndarray """ @@ -70,16 +78,15 @@ def kernel_simple_tf(x, kerneltype): return r - if not t: - t = (1./(2.*G.lmax) * np.power(2, np.arange(Nf-2, -1, -1))) + if not scales: + scales = (1./(2.*G.lmax) * np.power(2, np.arange(Nf-2, -1, -1))) - if len(t) != Nf - 1: - _logger.warning('You have specified more scales than ' - 'number of filters minus 1.') + if len(scales) != Nf - 1: + raise ValueError('len(scales) should be Nf-1.') - g = [lambda x: kernel_simple_tf(t[0] * x, 'sf')] + g = [lambda x: kernel(scales[0] * x, 'sf')] for i in range(Nf - 1): - g.append(lambda x, ind=i: kernel_simple_tf(t[ind] * x, 'wavelet')) + g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) super(SimpleTf, self).__init__(G, g, **kwargs) From 10c1ea6e1ab00ad2ba17107dab58dee882618635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:49:31 +0200 Subject: [PATCH 236/392] SimpleTf --> SimpleTight --- pygsp/filters/__init__.py | 4 ++-- pygsp/filters/{simpletf.py => simpletight.py} | 6 +++--- pygsp/tests/test_filters.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename pygsp/filters/{simpletf.py => simpletight.py} (95%) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 6ade5172..446ba20b 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -28,7 +28,7 @@ * :class:`Itersine`: design an itersine filter bank (tight frame) * :class:`MexicanHat`: design a mexican hat filter bank * :class:`Meyer`: design a Meyer filter bank -* :class:`SimpleTf`: design a simple tight frame filter bank +* :class:`SimpleTight`: design a simple tight frame filter bank (tight frame) * :class:`WarpedTranslates`: design a filter bank with a warping function **Filter banks of 2 filters: a low pass and a high pass** @@ -79,7 +79,7 @@ 'Papadakis', 'Regular', 'Simoncelli', - 'SimpleTf', + 'SimpleTight', 'WarpedTranslates' ] _APPROXIMATIONS = [ diff --git a/pygsp/filters/simpletf.py b/pygsp/filters/simpletight.py similarity index 95% rename from pygsp/filters/simpletf.py rename to pygsp/filters/simpletight.py index 9d98f889..cbe33c3d 100644 --- a/pygsp/filters/simpletf.py +++ b/pygsp/filters/simpletight.py @@ -9,7 +9,7 @@ _logger = utils.build_logger(__name__) -class SimpleTf(Filter): +class SimpleTight(Filter): r""" Design a simple tight frame filter bank. @@ -30,7 +30,7 @@ class SimpleTf(Filter): -------- >>> from pygsp import graphs, filters >>> G = graphs.Logo() - >>> F = filters.SimpleTf(G) + >>> g = filters.SimpleTight(G) """ @@ -89,4 +89,4 @@ def kernel(x, kerneltype): for i in range(Nf - 1): g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) - super(SimpleTf, self).__init__(G, g, **kwargs) + super(SimpleTight, self).__init__(G, g, **kwargs) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index df6a4046..d6492be1 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -124,7 +124,7 @@ def test_meyer(self): self._test_methods(f) def test_simpletf(self): - f = filters.SimpleTf(self._G, Nf=4) + f = filters.SimpleTight(self._G, Nf=4) self._test_methods(f) def test_warpedtranslates(self): From b844aab4d519489eea63713b82fc034658162ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:53:26 +0200 Subject: [PATCH 237/392] style: don't check files generated by plot_directive --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9229dc8f..4aff6c95 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ clean: rm -rf *.egg-info lint: - flake8 --doctests + flake8 --doctests --exclude=doc test: export DISPLAY = :99 test: From 7c6bf9ab36ae1fb4e85339ddbce97357a2de763e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 21:59:34 +0200 Subject: [PATCH 238/392] avoid sporadic build failures on RTD --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 24d3ba16..f7cd2574 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,6 +9,7 @@ 'sphinxcontrib.bibtex'] extensions.append('sphinx.ext.autodoc') +autodoc_mock_imports = ['_tkinter'] # Avoid sporadic build failures on RTD. autodoc_member_order = 'groupwise' # alphabetical, groupwise, bysource extensions.append('matplotlib.sphinxext.plot_directive') From e3098ce773ca221f07b4b31d1d580988acc90d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 22:41:02 +0200 Subject: [PATCH 239/392] doc: show frequency response of filter banks --- pygsp/filters/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 446ba20b..5f3e01d5 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -43,6 +43,23 @@ * :class:`Heat`: design an heat kernel filter * :class:`Expwin`: design an exponential window filter +The below code shows the frequency response of each of those filter banks. + +.. plot:: + :context: reset + + >>> import matplotlib.pyplot as plt + >>> from pygsp import graphs, filters + >>> + >>> G = graphs.Logo() + >>> G.estimate_lmax() + >>> + >>> for filt in sorted(filters._FILTERS): + ... if filt not in ['Filter', 'Gabor', 'WarpedTranslates']: + ... fig, ax = plt.subplots(figsize=(10, 4)) + ... getattr(filters, filt)(G).plot(ax=ax) + ... ax.set_title(filt) # doctest: +SKIP + Moreover, two approximation methods are provided for fast filtering. The computational complexity of filtering with those approximations is linear with the number of edges. The complexity of the exact solution, which is to use the From b36d38220d8978a3814f927632d1f79e1d5540fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 22:41:36 +0200 Subject: [PATCH 240/392] fix Meyer filterbank --- pygsp/filters/meyer.py | 10 +++++----- pygsp/filters/simpletight.py | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 859e02fa..e1ee10a7 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -40,14 +40,14 @@ def __init__(self, G, Nf=6, scales=None, **kwargs): scales = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) if len(scales) != Nf - 1: - raise ValueError('The number of scales should be equal to ' - 'the number of filters minus 1.') + raise ValueError('len(scales) should be Nf-1.') + + g = [lambda x: kernel(scales[0] * x, 'scaling_function')] - g = [lambda x: kernel_meyer(scales[0] * x, 'scaling_function')] for i in range(Nf - 1): - g.append(lambda x: kernel_meyer(scales[i] * x, 'wavelet')) + g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) - def kernel_meyer(x, kernel_type): + def kernel(x, kernel_type): r""" Evaluates Meyer function and scaling function diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index cbe33c3d..dc1f5821 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -60,9 +60,10 @@ def kernel(x, kerneltype): l2 = 0.5 l3 = 1. - h = lambda x: np.sin(np.pi*x/2.)**2 + def h(x): + return np.sin(np.pi*x/2.)**2 - r1ind = x < l1 + r1ind = (x < l1) r2ind = (x >= l1) * (x < l2) r3ind = (x >= l2) * (x < l3) From d80c14aeff8f3de509a4ddd39a0e2d96a4d6bd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 22:42:56 +0200 Subject: [PATCH 241/392] doctest: larger tolerance --- pygsp/filters/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 879a1856..a1c0a9cc 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -279,8 +279,8 @@ def synthesis(self, c, method='chebyshev', order=30): >>> g.plot() >>> G.plot_signal(s1[:, 0]) >>> G.plot_signal(s2[:, 0]) - >>> print('{:.2f}'.format(np.linalg.norm(s1 - s2))) - 0.30 + >>> print('{:.1f}'.format(np.linalg.norm(s1 - s2))) + 0.3 Perfect reconstruction with Itersine, a tight frame: From 9a9dab30840e53382e226b1c4dd235488751e704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 27 Aug 2017 23:11:37 +0200 Subject: [PATCH 242/392] intro tutorial: show Fourier on ring graph --- doc/tutorials/intro.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 2bd2f0b1..657f14ee 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -155,6 +155,23 @@ which assign a set of values (a vector in :math:`\mathbb{R}^d`) at every node ... ax.set_axis_off() >>> fig.tight_layout() +The parallel with classical signal processing is best seen on a ring graph, +where the graph Fourier basis is equivalent to the classical Fourier basis. +The following plot shows some eigenvectors drawn on a 1D and 2D embedding of +the ring graph. While the signals are easier to interpret on a 1D plot, the 2D +plot best represents the graph. + +.. plot:: + :context: close-figs + + >>> G2 = graphs.Ring(N=50) + >>> G2.compute_fourier_basis() + >>> fig, axes = plt.subplots(1, 2, figsize=(10, 4)) + >>> G2.plot_signal(G2.U[:, 4], ax=axes[0]) + >>> G2.set_coordinates('line1D') + >>> G2.plot_signal(G2.U[:, 1:4], ax=axes[1]) + >>> fig.tight_layout() + Filters ------- From bee6cc6bf1375686da3e0af0ace05012ca744250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 10:27:05 +0200 Subject: [PATCH 243/392] plotting: factor & improve coordinates creation --- pygsp/plotting.py | 164 +++++++++++++++------------------------------- 1 file changed, 53 insertions(+), 111 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 986f3a1c..8610adb9 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -220,19 +220,13 @@ def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): # TODO integrate param when G is a clustered graph if show_edges: - ki, kj = np.nonzero(G.A) if G.is_directed(): raise NotImplementedError else: - if G.coords.shape[1] == 2: - # TODO: use np.stack - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) + if G.coords.shape[1] == 2: # if isinstance(G.plotting['vertex_color'], list): # ax.plot(x, y, linewidth=G.plotting['edge_width'], # color=G.plotting['edge_color'], @@ -243,6 +237,7 @@ def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): # s=vertex_size, # c=G.plotting['vertex_color'], zorder=2) # else: + x, y = _get_coords(G) ax.plot(x, y, linewidth=G.plotting['edge_width'], color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], @@ -250,38 +245,24 @@ def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): markerfacecolor=G.plotting['vertex_color']) if G.coords.shape[1] == 3: - # Very dirty way to display a 3d graph - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) - z = np.concatenate((np.expand_dims(G.coords[ki, 2], axis=0), - np.expand_dims(G.coords[kj, 2], axis=0))) - ii = range(0, x.shape[1]) - x2 = np.ndarray((0, 1)) - y2 = np.ndarray((0, 1)) - z2 = np.ndarray((0, 1)) - for i in ii: - x2 = np.append(x2, x[:, i]) - for i in ii: - y2 = np.append(y2, y[:, i]) - for i in ii: - z2 = np.append(z2, z[:, i]) - for i in range(0, x.shape[1] * 2, 2): - x3 = x2[i:i + 2] - y3 = y2[i:i + 2] - z3 = z2[i:i + 2] - ax.plot(x3, y3, z3, linewidth=G.plotting['edge_width'], + # TODO: very dirty. Cannot we prepare a set of lines? + x, y, z = _get_coords(G) + for i in range(0, x.size, 2): + x2, y2, z2 = x[i:i+2], y[i:i+2], z[i:i+2] + ax.plot(x2, y2, z2, linewidth=G.plotting['edge_width'], color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], marker='o', markersize=vertex_size/10, markerfacecolor=G.plotting['vertex_color']) + else: + # TODO: is ax.plot(G.coords[:, 0], G.coords[:, 1], 'bo') faster? if G.coords.shape[1] == 2: ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', s=vertex_size, c=G.plotting['vertex_color']) + if G.coords.shape[1] == 3: ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], marker='o', s=vertex_size, @@ -302,13 +283,12 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): # TODO handling when G is a list of graphs - ki, kj = np.nonzero(G.A) if G.is_directed(): raise NotImplementedError + else: + if G.coords.shape[1] == 2: - adj = np.concatenate((np.expand_dims(ki, axis=1), - np.expand_dims(kj, axis=1)), axis=1) window = qtg.GraphicsWindow() window.setWindowTitle(plot_name) @@ -333,6 +313,8 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): else: pen = None + adj = _get_coords(G, edge_list=True) + g = qtg.GraphItem(pos=G.coords, adj=adj, pen=pen, size=vertex_size/10) view.addItem(g) @@ -349,30 +331,10 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): widget.show() widget.setWindowTitle(plot_name) - # Very dirty way to display a 3d graph - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) - z = np.concatenate((np.expand_dims(G.coords[ki, 2], axis=0), - np.expand_dims(G.coords[kj, 2], axis=0))) - ii = range(0, x.shape[1]) - x2 = np.ndarray((0, 1)) - y2 = np.ndarray((0, 1)) - z2 = np.ndarray((0, 1)) - for i in ii: - x2 = np.append(x2, x[:, i]) - for i in ii: - y2 = np.append(y2, y[:, i]) - for i in ii: - z2 = np.append(z2, z[:, i]) - - pts = np.concatenate((np.expand_dims(x2, axis=1), - np.expand_dims(y2, axis=1), - np.expand_dims(z2, axis=1)), axis=1) - if show_edges: - g = gl.GLLinePlotItem(pos=pts, mode='lines', + x, y, z = _get_coords(G) + pos = np.stack((x, y, z), axis=1) + g = gl.GLLinePlotItem(pos=pos, mode='lines', color=G.plotting['edge_color']) widget.addItem(g) @@ -559,58 +521,41 @@ def _plt_plot_signal(G, signal, show_edges, limits, plot_name, ax, vertex_size, colorbar=True): if show_edges: - ki, kj = np.nonzero(G.A) if G.is_directed(): raise NotImplementedError else: + if G.coords.ndim == 1: pass + elif G.coords.shape[1] == 2: - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) + x, y = _get_coords(G) ax.plot(x, y, linewidth=G.plotting['edge_width'], color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], zorder=1) + elif G.coords.shape[1] == 3: - # Very dirty way to display 3D graph edges - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) - z = np.concatenate((np.expand_dims(G.coords[ki, 2], axis=0), - np.expand_dims(G.coords[kj, 2], axis=0))) - ii = range(0, x.shape[1]) - x2 = np.ndarray((0, 1)) - y2 = np.ndarray((0, 1)) - z2 = np.ndarray((0, 1)) - for i in ii: - x2 = np.append(x2, x[:, i]) - for i in ii: - y2 = np.append(y2, y[:, i]) - for i in ii: - z2 = np.append(z2, z[:, i]) - for i in range(0, x.shape[1] * 2, 2): - x3 = x2[i:i + 2] - y3 = y2[i:i + 2] - z3 = z2[i:i + 2] - ax.plot(x3, y3, z3, linewidth=G.plotting['edge_width'], + # TODO: very dirty. Cannot we prepare a set of lines? + x, y, z = _get_coords(G) + for i in range(0, x.size, 2): + x2, y2, z2 = x[i:i+2], y[i:i+2], z[i:i+2] + ax.plot(x2, y2, z2, linewidth=G.plotting['edge_width'], color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], zorder=1) - # Plot signal if G.coords.ndim == 1: ax.plot(G.coords, signal) ax.set_ylim(limits) + elif G.coords.shape[1] == 2: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], s=vertex_size, c=signal, zorder=2, vmin=limits[0], vmax=limits[1]) + elif G.coords.shape[1] == 3: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], s=vertex_size, c=signal, zorder=2, @@ -642,43 +587,23 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): # Plot edges if show_edges: - ki, kj = np.nonzero(G.A) + if G.is_directed(): raise NotImplementedError + else: - if G.coords.shape[1] == 2: - adj = np.concatenate((np.expand_dims(ki, axis=1), - np.expand_dims(kj, axis=1)), axis=1) + if G.coords.shape[1] == 2: + adj = _get_coords(G, edge_list=True) pen = tuple(np.array(G.plotting['edge_color']) * 255) g = qtg.GraphItem(pos=G.coords, adj=adj, symbolBrush=None, symbolPen=None, pen=pen) view.addItem(g) - if G.coords.shape[1] == 3: - # Very dirty way to display a 3d graph - x = np.concatenate((np.expand_dims(G.coords[ki, 0], axis=0), - np.expand_dims(G.coords[kj, 0], axis=0))) - y = np.concatenate((np.expand_dims(G.coords[ki, 1], axis=0), - np.expand_dims(G.coords[kj, 1], axis=0))) - z = np.concatenate((np.expand_dims(G.coords[ki, 2], axis=0), - np.expand_dims(G.coords[kj, 2], axis=0))) - ii = range(0, x.shape[1]) - x2 = np.ndarray((0, 1)) - y2 = np.ndarray((0, 1)) - z2 = np.ndarray((0, 1)) - for i in ii: - x2 = np.append(x2, x[:, i]) - for i in ii: - y2 = np.append(y2, y[:, i]) - for i in ii: - z2 = np.append(z2, z[:, i]) - - pts = np.concatenate((np.expand_dims(x2, axis=1), - np.expand_dims(y2, axis=1), - np.expand_dims(z2, axis=1)), axis=1) - - g = gl.GLLinePlotItem(pos=pts, mode='lines', + elif G.coords.shape[1] == 3: + x, y, z = _get_coords(G) + pos = np.stack((x, y, z), axis=1) + g = gl.GLLinePlotItem(pos=pos, mode='lines', color=G.plotting['edge_color']) widget.addItem(g) @@ -762,3 +687,20 @@ def plot_spectrogram(G, node_idx=None): global _qtg_windows _qtg_windows.append(w) + + +def _get_coords(G, edge_list=False): + + v_in, v_out, _ = G.get_edge_list() + + if edge_list: + return np.stack((v_in, v_out), axis=1) + + coords = [np.stack((G.coords[v_in, d], G.coords[v_out, d]), axis=0) + for d in range(G.coords.shape[1])] + + if G.coords.shape[1] == 2: + return coords + + elif G.coords.shape[1] == 3: + return [coord.reshape(-1, order='F') for coord in coords] From 25578194028e16c82cfbcfa7b2748d90d74e8715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 10:39:58 +0200 Subject: [PATCH 244/392] plotting: fix plot_signal for 3D pyqtgraph --- pygsp/plotting.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 8610adb9..6000d12c 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -613,10 +613,7 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): [255, 255, 0, 255], [255, 0, 0, 255], [128, 0, 0, 255]]) cmap = qtg.ColorMap(pos, color) - mininum = min(signal) - maximum = max(signal) - - normalized_signal = [1 + 63 *(float(x) - mininum) / (maximum - mininum) for x in signal] + normalized_signal = 1 + 63 * (signal - limits[0]) / limits[1] - limits[0] if G.coords.shape[1] == 2: gp = qtg.ScatterPlotItem(G.coords[:, 0], @@ -624,10 +621,11 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): size=vertex_size/10, brush=cmap.map(normalized_signal, 'qcolor')) view.addItem(gp) + if G.coords.shape[1] == 3: gp = gl.GLScatterPlotItem(pos=G.coords, size=vertex_size/3, - color=signal) + color=cmap.map(normalized_signal, 'float')) widget.addItem(gp) # Multiple windows handling From 500c0a7f92e3982580696fd1bd59c3c90ed6b691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 10:51:22 +0200 Subject: [PATCH 245/392] plotting: clean up --- pygsp/plotting.py | 73 ++++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 6000d12c..d0c78285 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -227,16 +227,6 @@ def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): else: if G.coords.shape[1] == 2: -# if isinstance(G.plotting['vertex_color'], list): -# ax.plot(x, y, linewidth=G.plotting['edge_width'], -# color=G.plotting['edge_color'], -# linestyle=G.plotting['edge_style'], -# marker='', zorder=1) -# -# ax.scatter(G.coords[:, 0], G.coords[:, 1], marker='o', -# s=vertex_size, -# c=G.plotting['vertex_color'], zorder=2) -# else: x, y = _get_coords(G) ax.plot(x, y, linewidth=G.plotting['edge_width'], color=G.plotting['edge_color'], @@ -276,8 +266,6 @@ def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): except KeyError: pass - # threading.Thread(None, _thread, None, (G, show_edges, savefig)).start() - def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): @@ -295,19 +283,6 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): view = window.addViewBox() view.setAspectLocked() -# extra_args = {} -# if isinstance(G.plotting['vertex_color'], list): -# extra_args['symbolPen'] = [qtg.mkPen(v_col) for v_col in G.plotting['vertex_color']] -# extra_args['brush'] = [qtg.mkBrush(v_col) for v_col in G.plotting['vertex_color']] -# elif isinstance(G.plotting['vertex_color'], int): -# extra_args['symbolPen'] = G.plotting['vertex_color'] -# extra_args['brush'] = G.plotting['vertex_color'] - - # Define syntactic sugar mapping keywords for the display options -# for plot_args, qtg_args in [('vertex_mask', 'mask'), ('edge_color', 'pen'), ('symbolPen', 'symbolPen')]: -# if plot_args in G.plotting: -# extra_args[qtg_args] = G.plotting[plot_args] - if show_edges: pen = tuple(np.array(G.plotting['edge_color']) * 255) else: @@ -324,8 +299,7 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): elif G.coords.shape[1] == 3: if not QtGui.QApplication.instance(): - # We want only one application. - QtGui.QApplication([]) + QtGui.QApplication([]) # We want only one application. widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() @@ -399,24 +373,20 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, lambdas = np.linspace(0, G.lmax, npoints) - # Apply the filter fd = filters.evaluate(lambdas) - # Plot the filter if filters.Nf == 1: ax.plot(lambdas, fd, linewidth=line_width) else: for fd_i in fd: ax.plot(lambdas, fd_i, linewidth=line_width) - # Plot eigenvalues if plot_eigenvalues: ax.plot(G.e, np.zeros(G.N), 'xk', markeredgewidth=x_width, markersize=x_size) # TODO: plot highlighted eigenvalues - # Plot the sum if show_sum: test_sum = np.sum(np.power(fd, 2), 0) ax.plot(lambdas, test_sum, 'k', linewidth=line_width) @@ -576,16 +546,15 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): if G.coords.shape[1] == 2: window = qtg.GraphicsWindow(plot_name) view = window.addViewBox() + elif G.coords.shape[1] == 3: if not QtGui.QApplication.instance(): - # We want only one application. - QtGui.QApplication([]) + QtGui.QApplication([]) # We want only one application. widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() widget.setWindowTitle(plot_name) - # Plot edges if show_edges: if G.is_directed(): @@ -607,28 +576,26 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): color=G.plotting['edge_color']) widget.addItem(g) - # Plot signal on top pos = [1, 8, 24, 40, 56, 64] color = np.array([[0, 0, 143, 255], [0, 0, 255, 255], [0, 255, 255, 255], [255, 255, 0, 255], [255, 0, 0, 255], [128, 0, 0, 255]]) cmap = qtg.ColorMap(pos, color) - normalized_signal = 1 + 63 * (signal - limits[0]) / limits[1] - limits[0] + signal = 1 + 63 * (signal - limits[0]) / limits[1] - limits[0] if G.coords.shape[1] == 2: gp = qtg.ScatterPlotItem(G.coords[:, 0], G.coords[:, 1], size=vertex_size/10, - brush=cmap.map(normalized_signal, 'qcolor')) + brush=cmap.map(signal, 'qcolor')) view.addItem(gp) if G.coords.shape[1] == 3: gp = gl.GLScatterPlotItem(pos=G.coords, size=vertex_size/3, - color=cmap.map(normalized_signal, 'float')) + color=cmap.map(signal, 'float')) widget.addItem(gp) - # Multiple windows handling if G.coords.shape[1] == 2: global _qtg_windows _qtg_windows.append(window) @@ -659,28 +626,38 @@ def plot_spectrogram(G, node_idx=None): from pygsp import features if not qtg_import: - raise NotImplementedError("You need pyqtgraph to plot the spectrogram at the moment. Please install dependency and retry.") + raise NotImplementedError('You need pyqtgraph to plot the spectrogram ' + 'at the moment. Please install and retry.') if not hasattr(G, 'spectr'): features.compute_spectrogram(G) M = G.spectr.shape[1] - spectr = np.ravel(G.spectr[node_idx, :] if node_idx is not None else G.spectr) - min_spec, max_spec = np.min(spectr), np.max(spectr) + spectr = G.spectr[node_idx, :] if node_idx is not None else G.spectr + spectr = np.ravel(spectr) + min_spec, max_spec = spectr.min(), spectr.max() pos = np.array([0., 0.25, 0.5, 0.75, 1.]) - color = np.array([[20, 133, 212, 255], [53, 42, 135, 255], [48, 174, 170, 255], - [210, 184, 87, 255], [249, 251, 14, 255]], dtype=np.ubyte) + color = [[20, 133, 212, 255], [53, 42, 135, 255], [48, 174, 170, 255], + [210, 184, 87, 255], [249, 251, 14, 255]] + color = np.array(color, dtype=np.ubyte) cmap = qtg.ColorMap(pos, color) + spectr = (spectr.astype(float) - min_spec) / (max_spec - min_spec) + w = qtg.GraphicsWindow() - w.setWindowTitle("Spectrogramm of {}".format(G.gtype)) + w.setWindowTitle("Spectrogram of {}".format(G.gtype)) + label = 'frequencies {}:{:.2f}:{:.2f}'.format(0, G.lmax/M, G.lmax) v = w.addPlot(labels={'bottom': 'nodes', - 'left': 'frequencies {}:{:.2f}:{:.2f}'.format(0, G.lmax/M, G.lmax)}) + 'left': label}) v.setAspectLocked() - spi = qtg.ScatterPlotItem(np.repeat(np.arange(G.N), M), np.ravel(np.tile(np.arange(M), (1, G.N))), pxMode=False, symbol='s', - size=1, brush=cmap.map((spectr.astype(float) - min_spec)/(max_spec - min_spec), 'qcolor')) + spi = qtg.ScatterPlotItem(np.repeat(np.arange(G.N), M), + np.ravel(np.tile(np.arange(M), (1, G.N))), + pxMode=False, + symbol='s', + size=1, + brush=cmap.map(spectr, 'qcolor')) v.addItem(spi) global _qtg_windows From 9498b362db08f65a0369b10ba4cebbd2708ddd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 11:13:23 +0200 Subject: [PATCH 246/392] requirements: prettier plots for doc on RTD --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3e27026c..62c07e5b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ numpy scipy # Required for plotting. -matplotlib +matplotlib >= 2.0 # Prettier plots (especially for online doc at RTD). pyqtgraph PySide ; python_version < '3.5' PyQt5 ; python_version >= '3.5' From ba37a7d456a1be62d3fde7faa491b036f5d5cdb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 12:12:30 +0200 Subject: [PATCH 247/392] plotting: more comprehensive import error message --- pygsp/plotting.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index d0c78285..960f80e2 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -21,27 +21,33 @@ from __future__ import division +import traceback + import numpy as np +from pygsp import utils + +_logger = utils.build_logger(__name__) + try: import matplotlib.pyplot as plt # Not used directly, but needed for 3D projection. from mpl_toolkits.mplot3d import Axes3D # noqa - plt_import = True -except Exception as e: - print('ERROR : Could not import packages for matplotlib.') - print('Details : {}'.format(e)) - plt_import = False + _PLT_IMPORT = True +except Exception: + _logger.error('Cannot import packages for matplotlib: {}'.format( + traceback.format_exc())) + _PLT_IMPORT = False try: import pyqtgraph as qtg import pyqtgraph.opengl as gl from pyqtgraph.Qt import QtGui - qtg_import = True -except Exception as e: - print('ERROR : Could not import packages for pyqtgraph.') - print('Details : {}'.format(e)) - qtg_import = False + _QTG_IMPORT = True +except Exception: + _logger.error('Cannot import packages for pyqtgraph: {}'.format( + traceback.format_exc())) + _QTG_IMPORT = False BACKEND = 'pyqtgraph' @@ -205,9 +211,9 @@ def plot_graph(G, backend=None, **kwargs): if backend is None: backend = BACKEND - if backend == 'pyqtgraph' and qtg_import: + if backend == 'pyqtgraph' and _QTG_IMPORT: _qtg_plot_graph(G, **kwargs) - elif backend == 'matplotlib' and plt_import: + elif backend == 'matplotlib' and _PLT_IMPORT: _plt_plot_graph(G, **kwargs) else: raise ValueError('The {} backend is not available.'.format(backend)) @@ -478,9 +484,9 @@ def plot_signal(G, signal, backend=None, **kwargs): if backend is None: backend = BACKEND - if backend == 'pyqtgraph' and qtg_import: + if backend == 'pyqtgraph' and _QTG_IMPORT: _qtg_plot_signal(G, signal, **kwargs) - elif backend == 'matplotlib' and plt_import: + elif backend == 'matplotlib' and _PLT_IMPORT: _plt_plot_signal(G, signal, **kwargs) else: raise ValueError('The {} backend is not available.'.format(backend)) @@ -625,7 +631,7 @@ def plot_spectrogram(G, node_idx=None): """ from pygsp import features - if not qtg_import: + if not _QTG_IMPORT: raise NotImplementedError('You need pyqtgraph to plot the spectrogram ' 'at the moment. Please install and retry.') From e4e9dc165521687fa4f30271da1efde402574f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 12:19:18 +0200 Subject: [PATCH 248/392] nngraphs: more comprehensive import error message --- pygsp/graphs/nngraphs/nngraph.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 8e21dab0..3c238c62 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -1,19 +1,22 @@ # -*- coding: utf-8 -*- +import traceback + import numpy as np 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__) try: import pyflann as pfl - pfl_import = True + _PFL_IMPORT = True except Exception: - print('ERROR: Could not import pyflann. ' - 'Try to install it for faster kNN computations.') - pfl_import = False + _logger.warning('Cannot import pyflann (used for faster kNN computations):' + ' {}'.format(traceback.format_exc())) + _PFL_IMPORT = False class NNGraph(Graph): @@ -114,7 +117,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, spj = np.zeros((N * k)) spv = np.zeros((N * k)) - if self.use_flann and pfl_import: + if self.use_flann and _PFL_IMPORT: pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() From 053525b02f40a6234bc7768a7dda32b1641520c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 12:59:53 +0200 Subject: [PATCH 249/392] conda on RTD: allows to have matplotlib 2 for better style --- .environment.yml | 18 ++++++++++++++++++ .readthedocs.yml | 8 ++++++++ doc/conf.py | 1 - requirements.txt | 2 +- 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .environment.yml create mode 100644 .readthedocs.yml diff --git a/.environment.yml b/.environment.yml new file mode 100644 index 00000000..ccbeb36e --- /dev/null +++ b/.environment.yml @@ -0,0 +1,18 @@ +name: read-the-docs + +channels: + - conda-forge + - defaults +# Cannot do without defaults because of libgfortran. +# https://github.com/conda-forge/libgfortran-feedstock/issues/9 + +dependencies: + - python=3 + - numpy + - scipy + - matplotlib + - scikit-image + - sphinx + - numpydoc + - sphinxcontrib-bibtex + - sphinx_rtd_theme diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..19b8a5ca --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,8 @@ +formats: + - htmlzip + +python: + pip_install: true + +conda: + file: .environment.yml diff --git a/doc/conf.py b/doc/conf.py index f7cd2574..24d3ba16 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,7 +9,6 @@ 'sphinxcontrib.bibtex'] extensions.append('sphinx.ext.autodoc') -autodoc_mock_imports = ['_tkinter'] # Avoid sporadic build failures on RTD. autodoc_member_order = 'groupwise' # alphabetical, groupwise, bysource extensions.append('matplotlib.sphinxext.plot_directive') diff --git a/requirements.txt b/requirements.txt index 62c07e5b..3e27026c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ numpy scipy # Required for plotting. -matplotlib >= 2.0 # Prettier plots (especially for online doc at RTD). +matplotlib pyqtgraph PySide ; python_version < '3.5' PyQt5 ; python_version >= '3.5' From eb9c0b11b76b8674cfb5f257ac0c3fc1c72950d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 28 Aug 2017 16:52:08 +0200 Subject: [PATCH 250/392] naming: filtering functions are kernels in Fourier --- pygsp/filters/filter.py | 26 ++++++++++++++------------ pygsp/reduction.py | 10 +++++----- pygsp/tests/test_filters.py | 6 +++--- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index a1c0a9cc..d25c006b 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -23,21 +23,22 @@ class Filter(object): Parameters ---------- G : graph - The graph to which the filterbank is tailored. - filters : function or list of functions - A (list of) function defining the filterbank. One function per filter. + The graph to which the filter bank is tailored. + kernels : function or list of functions + A (list of) function(s) defining the filter bank in the Fourier domain. + One function per filter. Attributes ---------- G : Graph - The graph to which the filterbank was tailored. It is a reference to + The graph to which the filter bank was tailored. It is a reference to the graph passed when instantiating the class. - g : function or list of functions - A (list of) function defining the filterbank. One function per filter. + kernels : function or list of functions + A (list of) function defining the filter bank. One function per filter. Either passed by the user when instantiating the base class, either constructed by the derived classes. Nf : int - Number of filters in the filterbank. + Number of filters in the filter bank. Examples -------- @@ -55,14 +56,15 @@ class Filter(object): """ - def __init__(self, G, filters): + def __init__(self, G, kernels): self.G = G - if isinstance(filters, list): - self.g = filters - else: - self.g = [filters] + try: + iter(kernels) + except TypeError: + kernels = [kernels] + self.g = kernels self.Nf = len(self.g) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 7b70bff7..5e1fff6b 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -165,7 +165,7 @@ def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): L_reg = G.L + reg_eps * sparse.eye(G.N) K_reg = getattr(G.mr, 'K_reg', kron_reduction(L_reg, keep_inds)) green_kernel = getattr(G.mr, 'green_kernel', - filters.Filter(G, filters=lambda x: 1. / (reg_eps + x))) + filters.Filter(G, lambda x: 1. / (reg_eps + x))) alpha = K_reg.dot(f_subsampled) @@ -282,7 +282,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, L_reg = Gs[i].L + reg_eps * sparse.eye(Gs[i].N) Gs[i].mr['K_reg'] = kron_reduction(L_reg, ind) - Gs[i].mr['green_kernel'] = filters.Filter(Gs[i], filters=lambda x: 1./(reg_eps + x)) + Gs[i].mr['green_kernel'] = filters.Filter(Gs[i], lambda x: 1./(reg_eps + x)) return Gs @@ -420,7 +420,7 @@ def pyramid_analysis(Gs, f, **kwargs): for i in range(levels): # Low pass the signal - s_low = filters.Filter(Gs[i], filters=h_filters[i]).analysis(ca[i], **kwargs) + s_low = filters.Filter(Gs[i], h_filters[i]).analysis(ca[i], **kwargs) # Keep only the coefficient on the selected nodes ca.append(s_low[Gs[i+1].mr['idx']]) # Compute prediction @@ -594,9 +594,9 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): if use_landweber: x = np.zeros(N) z = np.concatenate((ca, pe), axis=0) - green_kernel = filters.Filter(G, filters=lambda x: 1./(x+reg_eps)) + green_kernel = filters.Filter(G, lambda x: 1./(x+reg_eps)) PhiVlt = green_kernel.analysis(S.T, **kwargs).T - filt = filters.Filter(G, filters=h_filter, **kwargs) + filt = filters.Filter(G, h_filter, **kwargs) for iteration in range(landweber_its): h_filtered_sig = filt.analysis(x, **kwargs) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index d6492be1..307618a1 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -90,11 +90,11 @@ def test_localize(self): np.testing.assert_allclose(F, gL) def test_custom_filter(self): - def _filter(x): + def kernel(x): return x / (1. + x) - f = filters.Filter(self._G, filters=_filter) + f = filters.Filter(self._G, kernels=kernel) self.assertEqual(f.Nf, 1) - self.assertIs(f.g[0], _filter) + self.assertIs(f.g[0], kernel) self._test_methods(f) def test_abspline(self): From 896a6f1378beb63d9f6b845b94aa664df5d01033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 29 Aug 2017 22:25:49 +0200 Subject: [PATCH 251/392] doc: use autodoc default flags --- doc/conf.py | 1 + doc/reference/features.rst | 2 -- doc/reference/filters.rst | 2 -- doc/reference/graphs.rst | 2 -- doc/reference/optimization.rst | 2 -- doc/reference/plotting.rst | 2 -- doc/reference/reduction.rst | 2 -- doc/reference/utils.rst | 3 --- 8 files changed, 1 insertion(+), 15 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 24d3ba16..fd18d2c5 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,6 +9,7 @@ 'sphinxcontrib.bibtex'] extensions.append('sphinx.ext.autodoc') +autodoc_default_flags = ['members', 'undoc-members'] autodoc_member_order = 'groupwise' # alphabetical, groupwise, bysource extensions.append('matplotlib.sphinxext.plot_directive') diff --git a/doc/reference/features.rst b/doc/reference/features.rst index f93de255..831ebfa7 100644 --- a/doc/reference/features.rst +++ b/doc/reference/features.rst @@ -3,5 +3,3 @@ Features ======== .. automodule:: pygsp.features - :members: - :undoc-members: diff --git a/doc/reference/filters.rst b/doc/reference/filters.rst index d2b7c389..bc7ccc1b 100644 --- a/doc/reference/filters.rst +++ b/doc/reference/filters.rst @@ -3,5 +3,3 @@ Filters ======= .. automodule:: pygsp.filters - :members: - :undoc-members: diff --git a/doc/reference/graphs.rst b/doc/reference/graphs.rst index 99dc07b6..99d21e6b 100644 --- a/doc/reference/graphs.rst +++ b/doc/reference/graphs.rst @@ -3,8 +3,6 @@ Graphs ====== .. automodule:: pygsp.graphs - :members: - :undoc-members: :exclude-members: Graph .. autoclass:: Graph diff --git a/doc/reference/optimization.rst b/doc/reference/optimization.rst index 2fada89c..9a2a8c7e 100644 --- a/doc/reference/optimization.rst +++ b/doc/reference/optimization.rst @@ -3,5 +3,3 @@ Optimization ============ .. automodule:: pygsp.optimization - :members: - :undoc-members: diff --git a/doc/reference/plotting.rst b/doc/reference/plotting.rst index 442ed6af..fb3db7e1 100644 --- a/doc/reference/plotting.rst +++ b/doc/reference/plotting.rst @@ -3,5 +3,3 @@ Plotting ======== .. automodule:: pygsp.plotting - :members: - :undoc-members: diff --git a/doc/reference/reduction.rst b/doc/reference/reduction.rst index 2ddbd38c..d8192d10 100644 --- a/doc/reference/reduction.rst +++ b/doc/reference/reduction.rst @@ -3,5 +3,3 @@ Reduction ========= .. automodule:: pygsp.reduction - :members: - :undoc-members: diff --git a/doc/reference/utils.rst b/doc/reference/utils.rst index b9d3e1f1..9942b1a5 100644 --- a/doc/reference/utils.rst +++ b/doc/reference/utils.rst @@ -3,6 +3,3 @@ Utilities ========= .. automodule:: pygsp.utils - :members: - :undoc-members: - :inherited-members: From ba66aadb51d1ab89c5ec2a03c595a3745321e8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 29 Aug 2017 23:53:07 +0200 Subject: [PATCH 252/392] doctests: populate global namespace to avoid imports --- doc/conf.py | 4 ++++ pygsp/filters/__init__.py | 1 - pygsp/filters/abspline.py | 1 - pygsp/filters/expwin.py | 1 - pygsp/filters/filter.py | 11 ----------- pygsp/filters/gabor.py | 1 - pygsp/filters/halfcosine.py | 1 - pygsp/filters/heat.py | 2 -- pygsp/filters/held.py | 1 - pygsp/filters/itersine.py | 1 - pygsp/filters/mexicanhat.py | 1 - pygsp/filters/meyer.py | 1 - pygsp/filters/papadakis.py | 1 - pygsp/filters/regular.py | 1 - pygsp/filters/simoncelli.py | 1 - pygsp/filters/simpletight.py | 1 - pygsp/filters/warpedtranslates.py | 1 - pygsp/graphs/airfoil.py | 1 - pygsp/graphs/barabasialbert.py | 1 - pygsp/graphs/comet.py | 1 - pygsp/graphs/community.py | 1 - pygsp/graphs/davidsensornet.py | 1 - pygsp/graphs/difference.py | 5 ----- pygsp/graphs/erdosrenyi.py | 1 - pygsp/graphs/fourier.py | 7 ------- pygsp/graphs/fullconnected.py | 1 - pygsp/graphs/graph.py | 15 --------------- pygsp/graphs/grid2d.py | 1 - pygsp/graphs/logo.py | 1 - pygsp/graphs/lowstretchtree.py | 1 - pygsp/graphs/minnesota.py | 1 - pygsp/graphs/nngraphs/bunny.py | 1 - pygsp/graphs/nngraphs/cube.py | 1 - pygsp/graphs/nngraphs/grid2dimgpatches.py | 1 - pygsp/graphs/nngraphs/imgpatches.py | 1 - pygsp/graphs/nngraphs/nngraph.py | 2 -- pygsp/graphs/nngraphs/sphere.py | 1 - pygsp/graphs/nngraphs/twomoons.py | 1 - pygsp/graphs/path.py | 1 - pygsp/graphs/randomregular.py | 1 - pygsp/graphs/randomring.py | 1 - pygsp/graphs/ring.py | 1 - pygsp/graphs/sensor.py | 1 - pygsp/graphs/stochasticblockmodel.py | 1 - pygsp/graphs/swissroll.py | 1 - pygsp/graphs/torus.py | 1 - pygsp/optimization.py | 1 - pygsp/plotting.py | 12 +++++------- pygsp/reduction.py | 4 ++-- pygsp/tests/test_all.py | 19 +++++++++++++++---- pygsp/utils.py | 3 --- 51 files changed, 26 insertions(+), 98 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index fd18d2c5..574fbc23 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -17,6 +17,10 @@ plot_html_show_source_link = False plot_html_show_formats = False plot_working_directory = '.' +plot_pre_code = """ +import numpy as np +from pygsp import graphs, filters, utils +""" extensions.append('numpydoc') numpydoc_show_class_members = False diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 5f3e01d5..1a9d2220 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -49,7 +49,6 @@ :context: reset >>> import matplotlib.pyplot as plt - >>> from pygsp import graphs, filters >>> >>> G = graphs.Logo() >>> G.estimate_lmax() diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 80f66e9d..2a5bf872 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -26,7 +26,6 @@ class Abspline(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Abspline(G) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 12b84923..f9bb0142 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -19,7 +19,6 @@ class Expwin(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Expwin(G) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index d25c006b..89ff92b3 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -42,8 +42,6 @@ class Filter(object): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, filters >>> >>> G = graphs.Logo() >>> my_filter = filters.Filter(G, lambda x: x / (1. + x)) @@ -118,8 +116,6 @@ def analysis(self, s, method='chebyshev', order=30): -------- Create a smooth graph signal by low-pass filtering white noise. - >>> import numpy as np - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> G.estimate_lmax() >>> s1 = np.random.uniform(size=(G.N, 4)) @@ -194,7 +190,6 @@ def evaluate(self, x, i=0): Frequency response of a low-pass filter. >>> import matplotlib.pyplot as plt - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> f = filters.Expwin(G) >>> G.compute_fourier_basis() @@ -259,8 +254,6 @@ def synthesis(self, c, method='chebyshev', order=30): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, filters >>> G = graphs.Sensor(30, seed=42) >>> G.estimate_lmax() @@ -364,7 +357,6 @@ def localize(self, i, **kwargs): -------- Visualize heat diffusion on a grid. - >>> from pygsp import graphs, filters >>> N = 20 >>> G = graphs.Grid2d(N) >>> G.estimate_lmax() @@ -422,7 +414,6 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> G.estimate_lmax() >>> f = filters.MexicanHat(G) @@ -502,8 +493,6 @@ def compute_frame(self, **kwargs): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, filters >>> >>> G = graphs.Logo() >>> G.estimate_lmax() diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 18e789b4..74a5272c 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -24,7 +24,6 @@ class Gabor(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> k = lambda x: x/(1.-x) >>> F = filters.Gabor(G, k); diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index 69cfde0b..e3c0ce4d 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -17,7 +17,6 @@ class HalfCosine(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.HalfCosine(G) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 3fbdbbe6..b8c54721 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -27,8 +27,6 @@ class Heat(Filter): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, filters >>> G = graphs.Logo() Regular heat kernel. diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index 587bc968..d4135fec 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -31,7 +31,6 @@ class Held(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Held(G) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 0ba59083..58b2e599 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -22,7 +22,6 @@ class Itersine(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Itersine(G) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 6c8597dd..6f29cd81 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -41,7 +41,6 @@ class MexicanHat(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> g = filters.MexicanHat(G) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index e1ee10a7..931ff912 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -28,7 +28,6 @@ class Meyer(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Meyer(G) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index a8337f81..9b9bb896 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -27,7 +27,6 @@ class Papadakis(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Papadakis(G) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 9006037d..2eecdbb2 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -35,7 +35,6 @@ class Regular(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Regular(G) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index f28252ad..8f51e207 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -28,7 +28,6 @@ class Simoncelli(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.Simoncelli(G) diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index dc1f5821..68484eb3 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -28,7 +28,6 @@ class SimpleTight(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> g = filters.SimpleTight(G) diff --git a/pygsp/filters/warpedtranslates.py b/pygsp/filters/warpedtranslates.py index 874aeb67..57faa7dc 100644 --- a/pygsp/filters/warpedtranslates.py +++ b/pygsp/filters/warpedtranslates.py @@ -19,7 +19,6 @@ class WarpedTranslates(Filter): Examples -------- - >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> F = filters.WarpedTranslates(G) Traceback (most recent call last): diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 72e79b24..dd8a95ff 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -13,7 +13,6 @@ class Airfoil(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Airfoil() """ diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 75185f19..5000b15c 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -32,7 +32,6 @@ class BarabasiAlbert(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.BarabasiAlbert(500) """ diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index c24bfc22..0db110e0 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -19,7 +19,6 @@ class Comet(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Comet() """ diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 7d2e0de0..46e001dc 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -40,7 +40,6 @@ class Community(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Community() """ diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 12663332..c7552986 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -19,7 +19,6 @@ class DavidSensorNet(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.DavidSensorNet(N=64) >>> G = graphs.DavidSensorNet(N=500) >>> G = graphs.DavidSensorNet(N=123) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 333b3856..f1cf3635 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -46,7 +46,6 @@ def compute_differential_operator(self): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Logo() >>> G.N, G.Ne (1130, 6262) @@ -104,8 +103,6 @@ def grad(self, s): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> G = graphs.Logo() >>> G.N, G.Ne (1130, 6262) @@ -147,8 +144,6 @@ def div(self, s): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> G = graphs.Logo() >>> G.N, G.Ne (1130, 6262) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 429a41d2..b4853b1c 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -29,7 +29,6 @@ class ErdosRenyi(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.ErdosRenyi(100, 0.05) """ diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 8b52212c..b77595ed 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -79,7 +79,6 @@ def compute_fourier_basis(self, recompute=False): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Torus() >>> G.compute_fourier_basis() >>> G.U.shape @@ -133,8 +132,6 @@ def gft(self, s): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> G = graphs.Logo() >>> s = np.random.normal(size=G.N) >>> s_hat = G.gft(s) @@ -168,8 +165,6 @@ def igft(self, s_hat): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> G = graphs.Logo() >>> s_hat = np.random.normal(size=G.N) >>> s = G.igft(s_hat) @@ -225,8 +220,6 @@ def gft_windowed_gabor(self, f, k): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> G = graphs.Logo() >>> s = np.random.normal(size=G.N) >>> C = G.gft_windowed_gabor(s, lambda x: x/(1.-x)) diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index 3dfb2fd5..0ff86367 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -16,7 +16,6 @@ class FullConnected(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.FullConnected(N=5) """ diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 2f7a557f..0bd12a5f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -68,8 +68,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): Examples -------- - >>> from pygsp import graphs - >>> import numpy as np >>> W = np.arange(4).reshape(2, 2) >>> G = graphs.Graph(W) @@ -129,8 +127,6 @@ def check_weights(self): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs >>> W = np.arange(4).reshape(2, 2) >>> G = graphs.Graph(W) >>> cw = G.check_weights() @@ -192,7 +188,6 @@ def update_graph_attr(self, *args, **kwargs): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Ring(N=10) >>> newW = G.W >>> newW[1] = 1 @@ -250,7 +245,6 @@ def copy_graph_attributes(self, Gn, ctype=True): Examples -------- - >>> from pygsp import graphs >>> Torus = graphs.Torus() >>> G = graphs.TwoMoons() >>> G.copy_graph_attributes(ctype=False, Gn=Torus); @@ -288,7 +282,6 @@ def set_coordinates(self, kind='spring', **kwargs): Examples -------- - >>> from pygsp import graphs >>> G = graphs.ErdosRenyi() >>> G.set_coordinates() >>> G.plot() @@ -377,8 +370,6 @@ def subgraph(self, ind): Examples -------- - >>> from pygsp import graphs - >>> import numpy as np >>> W = np.arange(16).reshape(4, 4) >>> G = graphs.Graph(W) >>> ind = [1, 3] @@ -418,7 +409,6 @@ def is_connected(self, recompute=False): Examples -------- >>> from scipy import sparse - >>> from pygsp import graphs >>> W = sparse.rand(10, 10, 0.2) >>> G = graphs.Graph(W=W) >>> connected = G.is_connected() @@ -484,7 +474,6 @@ def is_directed(self, recompute=False): Examples -------- >>> from scipy import sparse - >>> from pygsp import graphs >>> W = sparse.rand(10, 10, 0.2) >>> G = graphs.Graph(W=W) >>> directed = G.is_directed() @@ -518,7 +507,6 @@ def extract_components(self): Examples -------- >>> from scipy import sparse - >>> from pygsp import graphs, utils >>> W = sparse.rand(10, 10, 0.2) >>> W = utils.symmetrize(W) >>> G = graphs.Graph(W=W) @@ -590,7 +578,6 @@ def compute_laplacian(self, lap_type='combinatorial'): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Sensor(50) >>> G.L.shape (50, 50) @@ -678,7 +665,6 @@ def estimate_lmax(self, recompute=False): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Logo() >>> G.compute_fourier_basis() >>> print('{:.2f}'.format(G.lmax)) @@ -728,7 +714,6 @@ def get_edge_list(self): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Logo() >>> v_in, v_out, weights = G.get_edge_list() >>> v_in.shape, v_out.shape, weights.shape diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 37507d33..2bc7f571 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -25,7 +25,6 @@ class Grid2d(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Grid2d(shape=(32,)) """ diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 70c9d319..38df4dac 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -12,7 +12,6 @@ class Logo(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Logo() """ diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index ef5028e8..24e49014 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -17,7 +17,6 @@ class LowStretchTree(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.LowStretchTree(k=3) """ diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index bbb1e53e..47d7fe8d 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -21,7 +21,6 @@ class Minnesota(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Minnesota() """ diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 5efbb5ca..41e7766a 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -14,7 +14,6 @@ class Bunny(NNGraph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Bunny() """ diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 3795fa64..55b12849 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -23,7 +23,6 @@ class Cube(NNGraph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Cube(radius=5) """ diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 701066bb..443e73d4 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -25,7 +25,6 @@ class Grid2dImgPatches(Graph): Examples -------- - >>> from pygsp import graphs >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::32, ::32]) >>> G = graphs.Grid2dImgPatches(img) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 42bc946f..98085cdd 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -23,7 +23,6 @@ class ImgPatches(NNGraph): Examples -------- - >>> from pygsp import graphs >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::2, ::2]) >>> G = graphs.ImgPatches(img) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 3c238c62..e659bce5 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -64,8 +64,6 @@ class NNGraph(Graph): Examples -------- - >>> from pygsp import graphs - >>> import numpy as np >>> Xin = np.arange(90).reshape(30, 3) >>> G = graphs.NNGraph(Xin) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 49596f22..e3965b2f 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -23,7 +23,6 @@ class Sphere(NNGraph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Sphere(radius=5) """ diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index c44628f2..2ada9abe 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -35,7 +35,6 @@ class TwoMoons(NNGraph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.TwoMoons(moontype='standard', dim=4) >>> G.coords.shape (2000, 4) diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index 75ed3df9..be9d625f 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -17,7 +17,6 @@ class Path(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Path(N=16) References diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 5eb8fde2..f637fd65 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -37,7 +37,6 @@ class RandomRegular(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.RandomRegular() """ diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 5f287c89..3681ddae 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -19,7 +19,6 @@ class RandomRing(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.RandomRing(N=16) """ diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 7e02fcd8..34d75cb1 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -19,7 +19,6 @@ class Ring(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Ring() """ diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 101ddaf9..b37837cb 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -30,7 +30,6 @@ class Sensor(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Sensor(N=300) """ diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index d2f34947..63e83406 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -42,7 +42,6 @@ class StochasticBlockModel(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.StochasticBlockModel(N=1024, k=5) """ diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 3e6e4f93..fbf86fea 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -32,7 +32,6 @@ class SwissRoll(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.SwissRoll() """ diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 45cbf717..69ed89d3 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -19,7 +19,6 @@ class Torus(Graph): Examples -------- - >>> from pygsp import graphs >>> G = graphs.Torus(Nv=32) References diff --git a/pygsp/optimization.py b/pygsp/optimization.py index ac53e714..1b61f407 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -56,7 +56,6 @@ def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix Examples -------- - >>> from pygsp import optimization, graphs """ if A is None: diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 960f80e2..1edab177 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -151,7 +151,7 @@ def plot(O, **kwargs): Examples -------- - >>> from pygsp import graphs, plotting + >>> from pygsp import plotting >>> G = graphs.Logo() >>> plotting.plot(G) @@ -190,7 +190,7 @@ def plot_graph(G, backend=None, **kwargs): Examples -------- - >>> from pygsp import graphs, plotting + >>> from pygsp import plotting >>> G = graphs.Logo() >>> plotting.plot_graph(G) @@ -363,7 +363,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, Examples -------- - >>> from pygsp import graphs, filters, plotting + >>> from pygsp import plotting >>> G = graphs.Logo() >>> mh = filters.MexicanHat(G) >>> plotting.plot_filter(mh) @@ -446,8 +446,7 @@ def plot_signal(G, signal, backend=None, **kwargs): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, filters, plotting + >>> from pygsp import plotting >>> G = graphs.Grid2d(4) >>> signal = np.sin((np.arange(16) * 2*np.pi/16)) >>> plotting.plot_signal(G, signal) @@ -623,8 +622,7 @@ def plot_spectrogram(G, node_idx=None): Examples -------- - >>> import numpy as np - >>> from pygsp import graphs, plotting + >>> from pygsp import plotting >>> G = graphs.Ring(15) >>> plotting.plot_spectrogram(G) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 5e1fff6b..a8b68a03 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -46,7 +46,7 @@ def graph_sparsify(M, epsilon, maxiter=10): Examples -------- - >>> from pygsp import graphs, reduction + >>> from pygsp import reduction >>> G = graphs.Sensor(256, Nc=20, distribute=True) >>> epsilon = 0.4 >>> G2 = reduction.graph_sparsify(G, epsilon) @@ -229,7 +229,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, Examples -------- - >>> from pygsp import graphs, reduction + >>> from pygsp import reduction >>> levels = 5 >>> G = graphs.Sensor(N=512) >>> G.compute_fourier_basis() diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index 7b4c0842..e6b675b5 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -21,17 +21,28 @@ def gen_recursive_file(root, ext): yield os.path.join(root, name) -def test_docstrings(root, ext): +def test_docstrings(root, ext, setup=None): files = list(gen_recursive_file(root, ext)) - return doctest.DocFileSuite(*files, module_relative=False) + return doctest.DocFileSuite(*files, setUp=setup, module_relative=False) + + +def setup(doctest): + import numpy + import pygsp + doctest.globs = { + 'graphs': pygsp.graphs, + 'filters': pygsp.filters, + 'utils': pygsp.utils, + 'np': numpy, + } suites = [] suites.append(test_graphs.suite) suites.append(test_filters.suite) suites.append(test_utils.suite) -suites.append(test_docstrings('pygsp', '.py')) -suites.append(test_docstrings('.', '.rst')) +suites.append(test_docstrings('pygsp', '.py', setup)) +suites.append(test_docstrings('.', '.rst')) # No setup to not forget imports. suites.append(test_plotting.suite) # TODO: can SIGSEGV if not last suite = unittest.TestSuite(suites) diff --git a/pygsp/utils.py b/pygsp/utils.py index 9cb3762b..18cf09f7 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -135,7 +135,6 @@ def distanz(x, y=None): Examples -------- - >>> import numpy as np >>> from pygsp import utils >>> x = np.arange(3) >>> utils.distanz(x, x) @@ -230,7 +229,6 @@ def symmetrize(W, symmetrize_type='average'): Examples -------- - >>> import numpy as np >>> from pygsp import utils >>> x = np.array([[1,0],[3,4.]]) >>> x @@ -350,7 +348,6 @@ def repmatline(A, ncol=1, nrow=1): Examples -------- >>> from pygsp import utils - >>> import numpy as np >>> x = np.array([[1, 2], [3, 4]]) >>> x array([[1, 2], From e56ed935d5f6bf18eaec228a868a04bde6d8d5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 00:34:02 +0200 Subject: [PATCH 253/392] doc: plot filter banks in Fourier and time --- doc/conf.py | 13 +++++++++---- pygsp/filters/__init__.py | 15 --------------- pygsp/filters/abspline.py | 15 +++++++++++++-- pygsp/filters/expwin.py | 15 +++++++++++++-- pygsp/filters/gabor.py | 8 ++++---- pygsp/filters/halfcosine.py | 15 +++++++++++++-- pygsp/filters/heat.py | 15 ++++++++++++++- pygsp/filters/held.py | 15 +++++++++++++-- pygsp/filters/itersine.py | 15 +++++++++++++-- pygsp/filters/mexicanhat.py | 13 ++++++++++++- pygsp/filters/meyer.py | 15 +++++++++++++-- pygsp/filters/papadakis.py | 15 +++++++++++++-- pygsp/filters/regular.py | 15 +++++++++++++-- pygsp/filters/simoncelli.py | 15 +++++++++++++-- pygsp/filters/simpletight.py | 13 ++++++++++++- pygsp/tests/test_all.py | 1 + 16 files changed, 169 insertions(+), 44 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 574fbc23..8b95c962 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -12,19 +12,24 @@ autodoc_default_flags = ['members', 'undoc-members'] autodoc_member_order = 'groupwise' # alphabetical, groupwise, bysource +extensions.append('numpydoc') +numpydoc_show_class_members = False +numpydoc_use_plots = True # Add the plot directive whenever mpl is imported. + extensions.append('matplotlib.sphinxext.plot_directive') plot_include_source = True plot_html_show_source_link = False plot_html_show_formats = False plot_working_directory = '.' +plot_rcparams = { + 'figure.figsize': (10, 4) +} plot_pre_code = """ import numpy as np -from pygsp import graphs, filters, utils +from pygsp import graphs, filters, utils, plotting +plotting.BACKEND = 'matplotlib' """ -extensions.append('numpydoc') -numpydoc_show_class_members = False - exclude_patterns = ['_build'] source_suffix = '.rst' master_doc = 'index' diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 1a9d2220..ae6ddfef 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -43,21 +43,6 @@ * :class:`Heat`: design an heat kernel filter * :class:`Expwin`: design an exponential window filter -The below code shows the frequency response of each of those filter banks. - -.. plot:: - :context: reset - - >>> import matplotlib.pyplot as plt - >>> - >>> G = graphs.Logo() - >>> G.estimate_lmax() - >>> - >>> for filt in sorted(filters._FILTERS): - ... if filt not in ['Filter', 'Gabor', 'WarpedTranslates']: - ... fig, ax = plt.subplots(figsize=(10, 4)) - ... getattr(filters, filt)(G).plot(ax=ax) - ... ax.set_title(filt) # doctest: +SKIP Moreover, two approximation methods are provided for fast filtering. The computational complexity of filtering with those approximations is linear with diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 2a5bf872..5f977885 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -26,8 +26,19 @@ class Abspline(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Abspline(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Abspline(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index f9bb0142..10158005 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -19,8 +19,19 @@ class Expwin(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Expwin(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Expwin(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 74a5272c..09d89c32 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -25,11 +25,11 @@ class Gabor(Filter): Examples -------- >>> G = graphs.Logo() - >>> k = lambda x: x/(1.-x) - >>> F = filters.Gabor(G, k); + >>> k = lambda x: x / (1. - x) + >>> g = filters.Gabor(G, k); """ - def __init__(self, G, k, **kwargs): + def __init__(self, G, k): Nf = G.e.shape[0] @@ -37,4 +37,4 @@ def __init__(self, G, k, **kwargs): for i in range(Nf): g.append(lambda x, ii=i: k(x - G.e[ii])) - super(Gabor, self).__init__(G, g, **kwargs) + super(Gabor, self).__init__(G, g) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index e3c0ce4d..0055edcd 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -17,8 +17,19 @@ class HalfCosine(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.HalfCosine(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.HalfCosine(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index b8c54721..71b4ca8b 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -27,10 +27,10 @@ class Heat(Filter): Examples -------- - >>> G = graphs.Logo() Regular heat kernel. + >>> G = graphs.Logo() >>> g = filters.Heat(G, tau=[5, 10]) >>> print('{} filters'.format(g.Nf)) 2 filters @@ -45,6 +45,19 @@ class Heat(Filter): >>> print('{:.2f}'.format(np.linalg.norm(y[0]))) 1.00 + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Heat(G, tau=[5, 10, 100]) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) + """ def __init__(self, G, tau=10, normalize=False, **kwargs): diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index d4135fec..a9aebba6 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -31,8 +31,19 @@ class Held(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Held(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Held(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 58b2e599..e64e348d 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -22,8 +22,19 @@ class Itersine(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Itersine(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.HalfCosine(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ def __init__(self, G, Nf=6, overlap=2., **kwargs): diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 6f29cd81..fcbbc3d0 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -41,8 +41,19 @@ class MexicanHat(Filter): Examples -------- - >>> G = graphs.Logo() + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') >>> g = filters.MexicanHat(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 931ff912..6fe5b7e9 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -28,8 +28,19 @@ class Meyer(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Meyer(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Meyer(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index 9b9bb896..a5b53741 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -27,8 +27,19 @@ class Papadakis(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Papadakis(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Papadakis(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ def __init__(self, G, a=0.75, **kwargs): diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 2eecdbb2..d78b8043 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -35,8 +35,19 @@ class Regular(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Regular(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Regular(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ def __init__(self, G, d=3, **kwargs): diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 8f51e207..68a7c1fa 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -28,8 +28,19 @@ class Simoncelli(Filter): Examples -------- - >>> G = graphs.Logo() - >>> F = filters.Simoncelli(G) + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') + >>> g = filters.Simoncelli(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 68484eb3..0ca5e448 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -28,8 +28,19 @@ class SimpleTight(Filter): Examples -------- - >>> G = graphs.Logo() + + Filter bank's representation in Fourier and time (ring graph) domains. + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=20) + >>> G.estimate_lmax() + >>> G.set_coordinates('line1D') >>> g = filters.SimpleTight(G) + >>> s = g.localize(G.N // 2) + >>> s = utils.vec2mat(s, g.Nf) + >>> fig, axes = plt.subplots(1, 2) + >>> g.plot(ax=axes[0]) + >>> G.plot_signal(s, ax=axes[1]) """ diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index e6b675b5..5e098e15 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -29,6 +29,7 @@ def test_docstrings(root, ext, setup=None): def setup(doctest): import numpy import pygsp + pygsp.plotting.BACKEND = 'matplotlib' doctest.globs = { 'graphs': pygsp.graphs, 'filters': pygsp.filters, From 298bebc83188ebace2cf1fb41f415f8739b61095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 01:11:18 +0200 Subject: [PATCH 254/392] warped translates not implemented yet --- pygsp/filters/__init__.py | 2 -- pygsp/filters/warpedtranslates.py | 31 ------------------------------- pygsp/tests/test_filters.py | 5 ----- 3 files changed, 38 deletions(-) delete mode 100644 pygsp/filters/warpedtranslates.py diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index ae6ddfef..096edc12 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -29,7 +29,6 @@ * :class:`MexicanHat`: design a mexican hat filter bank * :class:`Meyer`: design a Meyer filter bank * :class:`SimpleTight`: design a simple tight frame filter bank (tight frame) -* :class:`WarpedTranslates`: design a filter bank with a warping function **Filter banks of 2 filters: a low pass and a high pass** @@ -81,7 +80,6 @@ 'Regular', 'Simoncelli', 'SimpleTight', - 'WarpedTranslates' ] _APPROXIMATIONS = [ 'compute_cheby_coeff', diff --git a/pygsp/filters/warpedtranslates.py b/pygsp/filters/warpedtranslates.py deleted file mode 100644 index 57faa7dc..00000000 --- a/pygsp/filters/warpedtranslates.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import Filter # prevent circular import in Python < 3.5 - - -class WarpedTranslates(Filter): - r""" - Vertex frequency filterbank - - Parameters - ---------- - G : graph - Nf : int - Number of filters - - References - ---------- - See :cite:`shuman2013spectrum` - - Examples - -------- - >>> G = graphs.Logo() - >>> F = filters.WarpedTranslates(G) - Traceback (most recent call last): - ... - NotImplementedError - - """ - - def __init__(self, G, Nf=6, **kwargs): - raise NotImplementedError diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 307618a1..01d7880d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -127,11 +127,6 @@ def test_simpletf(self): f = filters.SimpleTight(self._G, Nf=4) self._test_methods(f) - def test_warpedtranslates(self): - self.assertRaises(NotImplementedError, - filters.WarpedTranslates, self._G) - pass - def test_regular(self): f = filters.Regular(self._G) self._test_methods(f) From 97a73ddb244cc5dfbd2ee5f60bb94bdbc52c7433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 01:26:34 +0200 Subject: [PATCH 255/392] doc filters: use autosummary --- pygsp/filters/__init__.py | 74 +++++++++++++++++++++++------------- pygsp/filters/abspline.py | 3 +- pygsp/filters/expwin.py | 3 +- pygsp/filters/filter.py | 26 ++++++------- pygsp/filters/gabor.py | 11 ++---- pygsp/filters/halfcosine.py | 3 +- pygsp/filters/heat.py | 8 ++-- pygsp/filters/held.py | 3 +- pygsp/filters/itersine.py | 5 +-- pygsp/filters/mexicanhat.py | 3 +- pygsp/filters/meyer.py | 3 +- pygsp/filters/papadakis.py | 5 +-- pygsp/filters/regular.py | 5 +-- pygsp/filters/simoncelli.py | 6 +-- pygsp/filters/simpletight.py | 3 +- 15 files changed, 83 insertions(+), 78 deletions(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 096edc12..303669dc 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -4,44 +4,60 @@ The :mod:`pygsp.filters` module implements methods used for filtering (e.g. analysis, synthesis, evaluation) and defines commonly used filters that can be applied to :mod:`pygsp.graphs`. A filter is associated to a graph and is -defined with one or several functions. We define by filterbank a list of +defined with one or several functions. We define by filter bank a list of filters, usually centered around different frequencies, applied to a single graph. +Interface +--------- + The :class:`Filter` base class implements a common interface to all filters: -* :meth:`Filter.evaluate`: evaluate frequency response of the filterbank -* :meth:`Filter.analysis`: compute signal response to the filterbank -* :meth:`Filter.synthesis`: synthesize signal from response -* :meth:`Filter.compute_frame`: return a matrix operator -* :meth:`Filter.estimate_frame_bounds`: estimate lower and upper frame bounds -* :meth:`Filter.plot`: plot the filter frequency response -* :meth:`Filter.localize`: localize a kernel at a node (to visualize it) +.. autosummary:: + + Filter.evaluate + Filter.analysis + Filter.synthesis + Filter.compute_frame + Filter.estimate_frame_bounds + Filter.plot + Filter.localize + +Filters +------- Then, derived classes implement various common graph filters. **Filter banks of N filters** -* :class:`Abspline`: design a absspline filter bank -* :class:`Gabor`: design a Gabor filter bank -* :class:`HalfCosine`: design a half cosine filter bank (tight frame) -* :class:`Itersine`: design an itersine filter bank (tight frame) -* :class:`MexicanHat`: design a mexican hat filter bank -* :class:`Meyer`: design a Meyer filter bank -* :class:`SimpleTight`: design a simple tight frame filter bank (tight frame) +.. autosummary:: + + Abspline + Gabor + HalfCosine + Itersine + MexicanHat + Meyer + SimpleTight **Filter banks of 2 filters: a low pass and a high pass** -* :class:`Regular`: design 2 filters with the regular construction -* :class:`Held`: design 2 filters with the Held construction -* :class:`Simoncelli`: design 2 filters with the Simoncelli construction -* :class:`Papadakis`: design 2 filters with the Papadakis construction +.. autosummary:: + + Regular + Held + Simoncelli + Papadakis **Low pass filters** -* :class:`Heat`: design an heat kernel filter -* :class:`Expwin`: design an exponential window filter +.. autosummary:: + + Heat + Expwin +Approximations +-------------- Moreover, two approximation methods are provided for fast filtering. The computational complexity of filtering with those approximations is linear with @@ -51,15 +67,19 @@ **Chebyshev polynomials** -* :func:`compute_cheby_coeff` -* :func:`compute_jackson_cheby_coeff` -* :func:`cheby_op` -* :func:`cheby_rect` +.. autosummary:: + + compute_cheby_coeff + compute_jackson_cheby_coeff + cheby_op + cheby_rect **Lanczos algorithm** -* :func:`lanczos` -* :func:`lanczos_op` +.. autosummary:: + + lanczos + lanczos_op """ diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 5f977885..b5a4a04c 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -8,8 +8,7 @@ class Abspline(Filter): - r""" - Abspline filterbank + r"""Design an A B cubic spline wavelet filter bank. Parameters ---------- diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 10158005..4ae131ee 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -6,8 +6,7 @@ class Expwin(Filter): - r""" - Expwin filterbank + r"""Design an exponential window filter. Parameters ---------- diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 89ff92b3..75975f13 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -67,8 +67,10 @@ def __init__(self, G, kernels): self.Nf = len(self.g) def analysis(self, s, method='chebyshev', order=30): - r""" - Analyze, or filter, a signal with the filter bank. + r"""Compute signal response to the filter bank. + + This operation is also referred to as filtering a signal or as the + analysis operator. The method computes the transform coefficients of a signal :math:`s`, where the atoms of the transform dictionary are generalized @@ -170,8 +172,7 @@ def analysis(self, s, method='chebyshev', order=30): @utils.filterbank_handler def evaluate(self, x, i=0): - r""" - Evaluate the response of the filter bank at frequencies x. + r"""Evaluate the kernels at given frequencies. Parameters ---------- @@ -208,8 +209,9 @@ def inverse(self, c): raise NotImplementedError def synthesis(self, c, method='chebyshev', order=30): - r""" - Synthesize a signal from its filter bank coefficients. + r"""Synthesize signal from filter bank response. + + This operation is also referred to as the synthesis operator. The method synthesizes a signal :math:`s` from its coefficients :math:`c`, where the atoms of the transform dictionary are generalized @@ -330,8 +332,7 @@ def synthesis(self, c, method='chebyshev', order=30): return s def localize(self, i, **kwargs): - r""" - Localize the filter kernel at node i. + r"""Localize the kernels at a node (to visualize them). That is particularly useful to visualize a filter in the vertex domain. @@ -384,8 +385,7 @@ def tighten(self): def estimate_frame_bounds(self, min=0, max=None, N=1000, use_eigenvalues=False): - r""" - Compute approximate frame bounds for the filterbank. + r"""Estimate lower and upper frame bounds. The frame bounds are estimated using the vector :code:`np.linspace(min, max, N)` with min=0 and max=G.lmax by default. The eigenvalues G.e can @@ -459,8 +459,7 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, return sum_filters.min(), sum_filters.max() def compute_frame(self, **kwargs): - r""" - Compute the frame associated with the filter bank. + r"""Compute the associated frame. The size of the returned matrix operator :math:`D` is N x MN, where M is the number of filters and N the number of nodes. Multiplying this @@ -552,8 +551,7 @@ def can_dual_func(g, n, x): return gdual def plot(self, **kwargs): - r""" - Plot the filter. + r"""Plot the filter bank's frequency response. See :func:`pygsp.plotting.plot_filter`. """ diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 09d89c32..08fe1b1c 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -8,8 +8,10 @@ class Gabor(Filter): - r""" - Gabor filterbank + r"""Design a Gabor filter bank. + + Design a filter bank where the kernel *k* is placed at each graph + frequency. Parameters ---------- @@ -17,11 +19,6 @@ class Gabor(Filter): k : lambda function kernel - Notes - ----- - This function create a filterbank with the kernel *k*. Every filter is - centered in a different frequency. - Examples -------- >>> G = graphs.Logo() diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index 0055edcd..cfc00732 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -6,8 +6,7 @@ class HalfCosine(Filter): - r""" - HalfCosine filterbank + r"""Design an half cosine filter bank (tight frame). Parameters ---------- diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 71b4ca8b..e63089ed 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -8,13 +8,15 @@ class Heat(Filter): - r""" - Design an heat low-pass filter (simulates heat diffusion when applied). + r"""Design a filter bank of heat kernels. The filter is defined in the spectral domain as .. math:: - \hat{g}(x) = \exp \left( \frac{-\tau x}{\lambda_{\text{max}}} \right). + \hat{g}(x) = \exp \left( \frac{-\tau x}{\lambda_{\text{max}}} \right), + + and is as such a low-pass filter. An application of this filter to a signal + simulates heat diffusion. Parameters ---------- diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index a9aebba6..604af7aa 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -6,8 +6,7 @@ class Held(Filter): - r""" - Held filterbank + r"""Design 2 filters with the Held construction. This function create a parseval filterbank of :math:`2` filters. The low-pass filter is defined by the function diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index e64e348d..6945d948 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -6,10 +6,9 @@ class Itersine(Filter): - r""" - Itersine filterbank + r"""Design an itersine filter bank (tight frame). - Create an itersine half overlap filterbank of Nf filters. + Create an itersine half overlap filter bank of Nf filters. Going from 0 to lambda_max. Parameters diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index fcbbc3d0..19eb07e0 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -9,8 +9,7 @@ class MexicanHat(Filter): - r""" - Design the Mexican hat wavelet filter bank. + r"""Design a filter bank of Mexican hat wavelets. The Mexican hat wavelet is the second oder derivative of a Gaussian. Since we express the filter in the Fourier domain, we find: diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 6fe5b7e9..9de32a62 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -10,8 +10,7 @@ class Meyer(Filter): - r""" - Meyer filterbank + r"""Design a filter bank of Meyer wavelets (tight frame). Parameters ---------- diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index a5b53741..be48f1df 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -6,10 +6,9 @@ class Papadakis(Filter): - r""" - Papadakis filterbank + r"""Design 2 filters with the Papadakis construction. - This function create a parseval filterbank of :math:`2`. + This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by the function .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index d78b8043..fba9a5ca 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -6,10 +6,9 @@ class Regular(Filter): - r""" - Regular filterbank + r"""Design 2 filters with the regular construction. - This function creates a parseval filterbank :math:`2` filters. + This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by a function :math:`f_l(x)` between :math:`0` and :math:`2`. For :math:`d = 0`. diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 68a7c1fa..b3578787 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -6,11 +6,9 @@ class Simoncelli(Filter): - r""" - Simoncelli filterbank + r"""Design 2 filters with the Simoncelli construction. - - This function create a parseval filterbank of :math:`2`. + This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by the function .. math:: f_{l}=\begin{cases} 1 & \mbox{if }x\leq a\\ diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 0ca5e448..40c14657 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -10,8 +10,7 @@ class SimpleTight(Filter): - r""" - Design a simple tight frame filter bank. + r"""Design a simple tight frame filter bank (tight frame). These filters have been designed to be a simple tight frame wavelet filter bank. The kernel is similar to Meyer, but simpler. The function is From 9046e688bdec0d11987801fd02e95aa0ae3fcedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 02:54:03 +0200 Subject: [PATCH 256/392] doc graphs: use autosummary --- pygsp/graphs/__init__.py | 158 +++++++++++++--------- pygsp/graphs/airfoil.py | 3 +- pygsp/graphs/barabasialbert.py | 3 +- pygsp/graphs/comet.py | 3 +- pygsp/graphs/community.py | 3 +- pygsp/graphs/davidsensornet.py | 3 +- pygsp/graphs/difference.py | 15 +- pygsp/graphs/erdosrenyi.py | 3 +- pygsp/graphs/fourier.py | 41 +++--- pygsp/graphs/fullconnected.py | 3 +- pygsp/graphs/graph.py | 55 +++----- pygsp/graphs/grid2d.py | 3 +- pygsp/graphs/logo.py | 3 +- pygsp/graphs/lowstretchtree.py | 3 +- pygsp/graphs/minnesota.py | 3 +- pygsp/graphs/nngraphs/bunny.py | 3 +- pygsp/graphs/nngraphs/cube.py | 3 +- pygsp/graphs/nngraphs/grid2dimgpatches.py | 3 +- pygsp/graphs/nngraphs/imgpatches.py | 3 +- pygsp/graphs/nngraphs/nngraph.py | 3 +- pygsp/graphs/nngraphs/sphere.py | 3 +- pygsp/graphs/nngraphs/twomoons.py | 3 +- pygsp/graphs/path.py | 3 +- pygsp/graphs/randomregular.py | 3 +- pygsp/graphs/randomring.py | 3 +- pygsp/graphs/ring.py | 3 +- pygsp/graphs/sensor.py | 3 +- pygsp/graphs/stochasticblockmodel.py | 3 +- pygsp/graphs/swissroll.py | 3 +- pygsp/graphs/torus.py | 3 +- 30 files changed, 164 insertions(+), 183 deletions(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 0f017eba..891efc3b 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -5,91 +5,125 @@ object is either constructed from an adjacency matrix, or by instantiating one of the built-in graph models. +Interface +========= + The :class:`Graph` base class allows to construct a graph object from any adjacency matrix and provides a common interface to that object. Derived classes then allows to instantiate various standard graph models. -**Matrix operators** +Matrix operators +---------------- + +.. autosummary:: + + Graph.W + Graph.L + Graph.U + Graph.D + +Checks +------ + +.. autosummary:: + + Graph.check_weights + Graph.is_connected + Graph.is_directed + +Attributes computation +---------------------- + +.. autosummary:: + + Graph.compute_laplacian + Graph.estimate_lmax + Graph.compute_fourier_basis + Graph.compute_differential_operator + +Differential operators +---------------------- + +.. autosummary:: -* :attr:`Graph.W`: weight matrix -* :attr:`Graph.L`: Laplacian -* :attr:`Graph.U`: Fourier basis -* :attr:`Graph.D`: differential operator + Graph.grad + Graph.div -**Checks** +Localization +------------ -* :meth:`Graph.check_weights`: check the characteristics of the weights matrix -* :meth:`Graph.is_connected`: check the strong connectivity of the input graph -* :meth:`Graph.is_directed`: check if the graph has directed edges +.. autosummary:: -**Attributes computation** + Graph.modulate + Graph.translate -* :meth:`Graph.compute_laplacian`: compute a graph Laplacian -* :meth:`Graph.estimate_lmax`: estimate largest eigenvalue -* :meth:`Graph.compute_fourier_basis`: compute Fourier basis -* :meth:`Graph.compute_differential_operator`: compute differential operator +Transforms (frequency and vertex-frequency) +------------------------------------------- -**Differential operators** +.. autosummary:: -* :meth:`Graph.grad`: compute the gradient of a graph signal -* :meth:`Graph.div`: compute the divergence of a graph signal + Graph.gft + Graph.igft + Graph.gft_windowed + Graph.gft_windowed_gabor + Graph.gft_windowed_normalized -**Localization** +Plotting +-------- -* :meth:`Graph.modulate`: generalized modulation operator -* :meth:`Graph.translate`: generalized translation operator +.. autosummary:: -**Transforms** (frequency and vertex-frequency) + Graph.plot + Graph.plot_signal + Graph.plot_spectrogram -* :meth:`Graph.gft`: graph Fourier transform (GFT) -* :meth:`Graph.igft`: inverse graph Fourier transform -* :meth:`Graph.gft_windowed`: windowed GFT -* :meth:`Graph.gft_windowed_gabor`: Gabor windowed GFT -* :meth:`Graph.gft_windowed_normalized`: normalized windowed GFT +Others +------ -**Plotting** +.. autosummary:: -* :meth:`Graph.plot`: plot the graph -* :meth:`Graph.plot_signal`: plot a signal on that graph -* :meth:`Graph.plot_spectrogram`: plot the spectrogram for the graph object + Graph.get_edge_list + Graph.set_coordinates + Graph.subgraph + Graph.extract_components -**Others** +Graph models +============ -* :meth:`Graph.get_edge_list`: return an edge list (alternative representation) -* :meth:`Graph.set_coordinates`: set nodes' coordinates (for plotting) -* :meth:`Graph.subgraph`: create a subgraph -* :meth:`Graph.extract_components`: split the graph into connected components +.. autosummary:: -**Graph models** + Airfoil + BarabasiAlbert + Comet + Community + DavidSensorNet + ErdosRenyi + FullConnected + Grid2d + Logo + LowStretchTree + Minnesota + Path + RandomRegular + RandomRing + Ring + Sensor + StochasticBlockModel + SwissRoll + Torus -* :class:`Airfoil` -* :class:`BarabasiAlbert` -* :class:`Comet` -* :class:`Community` -* :class:`DavidSensorNet` -* :class:`ErdosRenyi` -* :class:`FullConnected` -* :class:`Grid2d` -* :class:`Logo` -* :class:`LowStretchTree` -* :class:`Minnesota` -* :class:`Path` -* :class:`RandomRegular` -* :class:`RandomRing` -* :class:`Ring` -* :class:`Sensor` -* :class:`StochasticBlockModel` -* :class:`SwissRoll` -* :class:`Torus` +Nearest-neighbors graphs constructed from point clouds +------------------------------------------------------ -**Nearest-neighbors graphs constructed from point clouds** +.. autosummary:: -* :class:`Bunny` -* :class:`Cube` -* :class:`ImgPatches` -* :class:`Grid2dImgPatches` -* :class:`Sphere` -* :class:`TwoMoons` + NNGraph + Bunny + Cube + ImgPatches + Grid2dImgPatches + Sphere + TwoMoons """ diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index dd8a95ff..375bde89 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -8,8 +8,7 @@ class Airfoil(Graph): - r""" - Create the airfoil graph. + r"""Airfoil graph. Examples -------- diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 5000b15c..e3407941 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -7,8 +7,7 @@ class BarabasiAlbert(Graph): - r""" - Create a graph following the preferential attachment concept like Barabasi-Albert graphs. + r"""Barabasi-Albert preferential attachment. The Barabasi-Albert graph is constructed by connecting nodes in two steps. First, m0 nodes are created. Then, nodes are added one by one. diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 0db110e0..fc4b3daa 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -7,8 +7,7 @@ class Comet(Graph): - r""" - Create a Comet graph. + r"""Comet graph. Parameters ---------- diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 46e001dc..011c29c9 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -11,8 +11,7 @@ class Community(Graph): - r""" - Create a community graph. + r"""Community graph. Parameters ---------- diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index c7552986..cb95d620 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -7,8 +7,7 @@ class DavidSensorNet(Graph): - r""" - Create a sensor network. + r"""Sensor network. Parameters ---------- diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index f1cf3635..30637ab6 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -13,8 +13,8 @@ class GraphDifference(object): @property def D(self): - r""" - Difference operator of the graph. + r"""Differential operator (for gradient and divergence). + Is computed by :func:`compute_differential_operator`. """ if not hasattr(self, '_D'): @@ -26,8 +26,7 @@ def D(self): return self._D def compute_differential_operator(self): - r""" - Compute the graph differential operator. + r"""Compute the graph differential operator (cached). The differential operator is a matrix such that @@ -37,7 +36,7 @@ def compute_differential_operator(self): Laplacian. It is used to compute the gradient and the divergence of a graph signal, see :meth:`grad` and :meth:`div`. - The result is cached and accessible by the :py:attr:`D` property. + The result is cached and accessible by the :attr:`D` property. See also -------- @@ -76,8 +75,7 @@ def compute_differential_operator(self): self._D = sparse.csc_matrix((Dv, (Dr, Dc)), shape=(n, self.N)) def grad(self, s): - r""" - Compute the graph gradient of a signal. + r"""Compute the gradient of a graph signal. The gradient of a signal :math:`s` is defined as @@ -118,8 +116,7 @@ def grad(self, s): return self.D.dot(s) def div(self, s): - r""" - Compute the graph divergence of a signal. + r"""Compute the divergence of a graph signal. The divergence of a signal :math:`s` is defined as diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index b4853b1c..b6712d92 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -7,8 +7,7 @@ class ErdosRenyi(Graph): - r""" - Create a random Erdos Renyi graph. + r"""Erdos Renyi graph. The Erdos Renyi graph is constructed by connecting nodes randomly. Each edge is included in the graph with probability p independent from every diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index b77595ed..1f3c798c 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -22,35 +22,33 @@ def _check_fourier_properties(self, name, desc): @property def U(self): - r""" - Fourier basis, i.e. the eigenvectors of the Laplacian. + r"""Fourier basis (eigenvectors of the Laplacian). + Is computed by :func:`compute_fourier_basis`. """ return self._check_fourier_properties('U', 'Fourier basis') @property def e(self): - r""" - The eigenvalues of the Laplacian. - Their square root are the graph frequencies. + r"""Eigenvalues of the Laplacian (square of graph frequencies). + Is computed by :func:`compute_fourier_basis`. """ return self._check_fourier_properties('e', 'eigenvalues vector') @property def mu(self): - r""" - Coherence of the Fourier basis. + r"""Coherence of the Fourier basis. + Is computed by :func:`compute_fourier_basis`. """ return self._check_fourier_properties('mu', 'Fourier basis coherence') def compute_fourier_basis(self, recompute=False): - r""" - Compute the Fourier basis of the graph. + r"""Compute the Fourier basis of the graph (cached). - The result is cached and accessible by the :py:attr:`U`, - :py:attr:`e`, :py:attr:`lmax`, and :py:attr:`mu` properties. + The result is cached and accessible by the :attr:`U`, :attr:`e`, + :attr:`lmax`, and :attr:`mu` properties. Parameters ---------- @@ -110,8 +108,7 @@ def compute_fourier_basis(self, recompute=False): self._mu = np.max(np.abs(self._U)) def gft(self, s): - r""" - Compute graph Fourier transform. + r"""Compute the graph Fourier transform. The graph Fourier transform of a signal :math:`s` is defined as @@ -143,8 +140,7 @@ def gft(self, s): return np.dot(np.conjugate(self.U.T), s) # True Hermitian here. def igft(self, s_hat): - r""" - Compute inverse graph Fourier transform. + r"""Compute the inverse graph Fourier transform. The inverse graph Fourier transform of a Fourier domain signal :math:`\hat{s}` is defined as @@ -176,8 +172,7 @@ def igft(self, s_hat): return np.dot(self.U, s_hat) def translate(self, f, i): - r""" - Translate the signal f to the node i. + r"""Translate the signal *f* to the node *i*. Parameters ---------- @@ -203,8 +198,7 @@ def translate(self, f, i): return ft def gft_windowed_gabor(self, f, k): - r""" - Gabor windowed graph Fourier transform. + r"""Gabor windowed graph Fourier transform. Parameters ---------- @@ -238,8 +232,7 @@ def gft_windowed_gabor(self, f, k): return C def gft_windowed(self, g, f, lowmemory=True): - r""" - Windowed graph Fourier transform. + r"""Windowed graph Fourier transform. Parameters ---------- @@ -289,8 +282,7 @@ def gft_windowed(self, g, f, lowmemory=True): return C def gft_windowed_normalized(self, g, f, lowmemory=True): - r""" - Normalized windowed graph Fourier transform. + r"""Normalized windowed graph Fourier transform. Parameters ---------- @@ -337,8 +329,7 @@ def gft_windowed_normalized(self, g, f, lowmemory=True): return C def _frame_matrix(self, g, normalize=False): - r""" - Create the GWFT frame. + r"""Create the GWFT frame. Parameters ---------- diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index 0ff86367..c781731d 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -6,8 +6,7 @@ class FullConnected(Graph): - r""" - Create a fully connected graph. + r"""Fully connected graph. Parameters ---------- diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 0bd12a5f..c404c157 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -10,8 +10,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): - r""" - The base graph class. + r"""Base graph class. * Provide a common interface (and implementation) to graph objects. * Can be instantiated to construct custom graphs from a weight matrix. @@ -109,8 +108,7 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.plotting.update(plotting) def check_weights(self): - r""" - Check the characteristics of the weights matrix. + r"""Check the characteristics of the weights matrix. Returns ------- @@ -165,8 +163,7 @@ def check_weights(self): 'diag_is_not_zero': diag_is_not_zero} def update_graph_attr(self, *args, **kwargs): - r""" - Recompute some attribute of the graph. + r"""Recompute some attribute of the graph. Parameters ---------- @@ -228,8 +225,7 @@ def update_graph_attr(self, *args, **kwargs): super(type(self), self).__init__(**graph_attr) def copy_graph_attributes(self, Gn, ctype=True): - r""" - Copy some parameters of the graph into a given one. + r"""Copy some parameters of the graph into a given one. Parameters ----------: @@ -265,8 +261,7 @@ def copy_graph_attributes(self, Gn, ctype=True): # TODO: an existing Fourier basis should be updated def set_coordinates(self, kind='spring', **kwargs): - r""" - Set the coordinates of the nodes. Used to position them when plotting. + r"""Set node's coordinates (their position when plotting). Parameters ---------- @@ -355,8 +350,7 @@ def set_coordinates(self, kind='spring', **kwargs): raise ValueError('Unexpected argument king={}.'.format(kind)) def subgraph(self, ind): - r""" - Create a subgraph from G keeping only the given indices. + r"""Create a subgraph given indices. Parameters ---------- @@ -385,8 +379,7 @@ def subgraph(self, ind): return Graph(sub_W, gtype="sub-{}".format(self.gtype)) def is_connected(self, recompute=False): - r""" - Check the strong connectivity of the input graph. Result is cached. + r"""Check the strong connectivity of the graph (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 @@ -451,8 +444,7 @@ def is_connected(self, recompute=False): return self._connected def is_directed(self, recompute=False): - r""" - Check if the graph has directed edges. Result is cached. + 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. @@ -490,8 +482,7 @@ def is_directed(self, recompute=False): return self._directed def extract_components(self): - r""" - Split the graph into several connected components. + r"""Split the graph into connected components. See :func:`is_connected` for the method used to determine connectedness. @@ -553,8 +544,7 @@ def extract_components(self): return graphs def compute_laplacian(self, lap_type='combinatorial'): - r""" - Compute a graph Laplacian. + r"""Compute a graph Laplacian. The result is accessible by the L attribute. @@ -624,9 +614,10 @@ def compute_laplacian(self, lap_type='combinatorial'): @property def lmax(self): - r""" - Largest eigenvalue of the graph Laplacian. Can be exactly computed by - :func:`compute_fourier_basis` or approximated by :func:`estimate_lmax`. + r"""Largest eigenvalue of the graph Laplacian. + + Can be exactly computed by :func:`compute_fourier_basis` or + approximated by :func:`estimate_lmax`. """ if not hasattr(self, '_lmax'): self.logger.warning('The largest eigenvalue G.lmax is not ' @@ -638,8 +629,7 @@ def lmax(self): return self._lmax def estimate_lmax(self, recompute=False): - r""" - Estimate the largest eigenvalue of the graph Laplacian. + r"""Estimate the Laplacian's largest eigenvalue (cached). The result is cached and accessible by the :attr:`lmax` property. @@ -699,8 +689,7 @@ def estimate_lmax(self, recompute=False): self._lmax = lmax def get_edge_list(self): - r""" - Return an edge list, an alternative representation of the graph. + r"""Return an edge list, an alternative representation of the graph. The weighted adjacency matrix is the canonical form used in this package to represent a graph as it is the easiest to work with when @@ -737,8 +726,7 @@ def get_edge_list(self): return v_in, v_out, weights def modulate(self, f, k): - r""" - Modulation the signal f to the frequency k. + r"""Modulate the signal *f* to the frequency *k*. Parameters ---------- @@ -761,8 +749,7 @@ def modulate(self, f, k): return fm def plot(self, **kwargs): - r""" - Plot the graph. + r"""Plot the graph. See :func:`pygsp.plotting.plot_graph`. """ @@ -770,8 +757,7 @@ def plot(self, **kwargs): plotting.plot_graph(self, **kwargs) def plot_signal(self, signal, **kwargs): - r""" - Plot a signal on that graph. + r"""Plot a signal on that graph. See :func:`pygsp.plotting.plot_signal`. """ @@ -779,8 +765,7 @@ def plot_signal(self, signal, **kwargs): plotting.plot_signal(self, signal, **kwargs) def plot_spectrogram(self, **kwargs): - r""" - Plot the spectrogram for the graph object. + r"""Plot the graph's spectrogram. See :func:`pygsp.plotting.plot_spectrogram`. """ diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 2bc7f571..3979b769 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -8,8 +8,7 @@ class Grid2d(Graph): - r""" - Create a 2-dimensional grid graph. + r"""2-dimensional grid graph. Parameters ---------- diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 38df4dac..6aa02a21 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -7,8 +7,7 @@ class Logo(Graph): - r""" - Create a graph with the GSP logo. + r"""GSP logo. Examples -------- diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index 24e49014..b92327b9 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -7,8 +7,7 @@ class LowStretchTree(Graph): - r""" - Create a low stretch tree graph. + r"""Low stretch tree graph. Parameters ---------- diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 47d7fe8d..88036acf 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -7,8 +7,7 @@ class Minnesota(Graph): - r""" - Create a community graph. + r"""Minnesota road graph. Parameters ---------- diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 41e7766a..82cbbdc3 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -5,8 +5,7 @@ class Bunny(NNGraph): - r""" - Create a graph of the Stanford bunny. + r"""Stanford bunny (NN-graph). References ---------- diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 55b12849..03aaeeb5 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -6,8 +6,7 @@ class Cube(NNGraph): - r""" - Creates the graph of an hyper-cube. + r"""Hyper-cube (NN-graph). Parameters ---------- diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 443e73d4..ce6ea6ac 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -5,8 +5,7 @@ class Grid2dImgPatches(Graph): - r""" - Create the union of an image patch graph with a 2-dimensional grid graph. + r"""Union of a patch graph with a 2D grid graph. Parameters ---------- diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 98085cdd..5ba64b07 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -5,8 +5,7 @@ class ImgPatches(NNGraph): - r""" - Create a nearest neighbors graph from patches of an image. + r"""NN-graph between patches of an image. Parameters ---------- diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index e659bce5..52e4c550 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -20,8 +20,7 @@ class NNGraph(Graph): - r""" - Create a nearest-neighbor graph from a point cloud. + r"""Nearest-neighbor graph from given point cloud. Parameters ---------- diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index e3965b2f..d1370eca 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -6,8 +6,7 @@ class Sphere(NNGraph): - r""" - Creates a spherical-shaped graph. + r"""Spherical-shaped graph (NN-graph). Parameters ---------- diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 2ada9abe..90a2cbcf 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -7,8 +7,7 @@ class TwoMoons(NNGraph): - r""" - Create a 2 dimensional graph of the Two Moons. + r"""Two Moons (NN-graph). Parameters ---------- diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index be9d625f..a31a7267 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -7,8 +7,7 @@ class Path(Graph): - r""" - Create a path graph. + r"""Path graph. Parameters ---------- diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index f637fd65..10b71481 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -8,8 +8,7 @@ class RandomRegular(Graph): - r""" - Create a random regular graph. + r"""Random k-regular graph. The random regular graph has the property that every node is connected to k other nodes. That graph is simple (without loops or double edges), diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 3681ddae..a58a388d 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -7,8 +7,7 @@ class RandomRing(Graph): - r""" - Create a ring graph. + r"""Ring graph with randomly sampled nodes. Parameters ---------- diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 34d75cb1..7839d189 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -7,8 +7,7 @@ class Ring(Graph): - r""" - Create a ring graph. + r"""K-regular ring graph. Parameters ---------- diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index b37837cb..e7848abd 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -8,8 +8,7 @@ class Sensor(Graph): - r""" - Create a random sensor graph. + r"""Random sensor graph. Parameters ---------- diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 63e83406..f8934f08 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -7,8 +7,7 @@ class StochasticBlockModel(Graph): - r""" - Create a graph generated with the Stochastic Block Model. + r"""Stochastic Block Model (SBM). The Stochastic Block Model graph is constructed by connecting nodes with a probability which depends on the cluster of the two nodes. One can define diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index fbf86fea..0110cd09 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -7,8 +7,7 @@ class SwissRoll(Graph): - r""" - Create a swiss roll graph. + r"""Sampled Swiss roll manifold. Parameters ---------- diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 69ed89d3..97121e62 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -7,8 +7,7 @@ class Torus(Graph): - r""" - Create a Torus graph. + r"""Sampled torus manifold. Parameters ---------- From 41c414d219fbe7fb4df3a9539ca93ecf3425c266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 05:24:15 +0200 Subject: [PATCH 257/392] graphs: fix minnesota --- pygsp/graphs/minnesota.py | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 88036acf..462ba9fd 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -1,22 +1,25 @@ # -*- coding: utf-8 -*- import numpy as np +from scipy import sparse from pygsp import utils from . import Graph # prevent circular import in Python < 3.5 class Minnesota(Graph): - r"""Minnesota road graph. + r"""Minnesota road network (from MatlabBGL). Parameters ---------- connect : bool - Change the graph to be connected. (default = True) + If True, the adjacency matrix is adjusted so that all edge weights are + equal to 1, and the graph is connected. Set to False to get the + original disconnected graph. References ---------- - See :cite:`gleich` + See :cite:`gleich`. Examples -------- @@ -34,29 +37,20 @@ def __init__(self, connect=True): "vertex_size": 30} if connect: - # Edit adjacency matrix - A = (A > 0).astype(int) - - # clean minnesota graph - A.setdiag(0) - - # missing edge needed to connect graph - A[349, 355] = 1 - A[355, 349] = 1 - - # change a handful of 2 values back to 1 - A[86, 88] = 1 - A[86, 88] = 1 - A[345, 346] = 1 - A[346, 345] = 1 - A[1707, 1709] = 1 - A[1709, 1707] = 1 - A[2289, 2290] = 1 - A[2290, 2289] = 1 + + # Missing edges needed to connect the graph. + A = sparse.lil_matrix(A) + A[348, 354] = 1 + A[354, 348] = 1 + A = sparse.csc_matrix(A) + + # Binarize: 8 entries are equal to 2 instead of 1. + A = (A > 0).astype(bool) gtype = 'minnesota' else: + gtype = 'minnesota-disconnected' super(Minnesota, self).__init__(W=A, coords=data['xy'], From cf7cb0afbcac17718d00d5373fe15d3754c3b9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 05:49:54 +0200 Subject: [PATCH 258/392] plotting: handle directed graphs as if they were not --- pygsp/plotting.py | 16 ++++++++++++++++ pygsp/tests/test_plotting.py | 16 +++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 1edab177..cdd327f8 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -211,6 +211,8 @@ def plot_graph(G, backend=None, **kwargs): if backend is None: backend = BACKEND + G = _handle_directed(G) + if backend == 'pyqtgraph' and _QTG_IMPORT: _qtg_plot_graph(G, **kwargs) elif backend == 'matplotlib' and _PLT_IMPORT: @@ -483,6 +485,8 @@ def plot_signal(G, signal, backend=None, **kwargs): if backend is None: backend = BACKEND + G = _handle_directed(G) + if backend == 'pyqtgraph' and _QTG_IMPORT: _qtg_plot_signal(G, signal, **kwargs) elif backend == 'matplotlib' and _PLT_IMPORT: @@ -683,3 +687,15 @@ def _get_coords(G, edge_list=False): elif G.coords.shape[1] == 3: return [coord.reshape(-1, order='F') for coord in coords] + + +def _handle_directed(G): + # FIXME: plot edge direction. For now we just symmetrize the weight matrix. + if not G.is_directed(): + return G + else: + from pygsp import graphs + G2 = graphs.Graph(utils.symmetrize(G.W)) + G2.coords = G.coords + G2.plotting = G.plotting + return G2 diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index e2a40f65..ef83f3c9 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -76,17 +76,11 @@ def test_plot_graphs(self): signal = np.arange(G.N) + 0.3 - if G.is_directed(): - self.assertRaises(NotImplementedError, - G.plot, backend='pyqtgraph') - self.assertRaises(NotImplementedError, - G.plot, backend='matplotlib') - else: - G.plot(backend='pyqtgraph') - G.plot(backend='matplotlib') - G.plot_signal(signal, backend='pyqtgraph') - G.plot_signal(signal, backend='matplotlib') - plotting.close_all() + G.plot(backend='pyqtgraph') + G.plot(backend='matplotlib') + G.plot_signal(signal, backend='pyqtgraph') + G.plot_signal(signal, backend='matplotlib') + plotting.close_all() def test_save(self): G = graphs.Logo() From b0c8e175ef471f42e6fffc844659155488201d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 05:55:54 +0200 Subject: [PATCH 259/392] graphs: improve Grid2d --- pygsp/graphs/grid2d.py | 50 +++++++++++++++----------------------- pygsp/tests/test_graphs.py | 8 ++---- 2 files changed, 21 insertions(+), 37 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 3979b769..25c85cf3 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -12,15 +12,10 @@ class Grid2d(Graph): Parameters ---------- - shape : int or tuple, optional - Dimensions of the 2-dimensional grid. Syntax: (height, width), - (height,), or height, where the last two options imply width = height. - Default is shape = (3,). - - Notes - ----- - The total number of nodes on the graph is N = height * width, that is, the - number of points in the grid. + N1 : int + Number of vertices along the first dimension. + N2 : int + Number of vertices along the second dimension (default N1). Examples -------- @@ -28,40 +23,33 @@ class Grid2d(Graph): """ - def __init__(self, shape=(3,), **kwargs): - # Parse shape - try: - h = shape[0] - try: - w = shape[1] - except IndexError: - w = h - except TypeError: - h = shape - w = h + def __init__(self, N1=16, N2=None, **kwargs): + + if N2 is None: + N2 = N1 + + N = N1 * N2 # Filling up the weight matrix this way is faster than # looping through all the grid points: - diag_1 = np.ones((h * w - 1,)) - diag_1[(w - 1)::w] = 0 - stride = w - diag_2 = np.ones((h * w - stride,)) + diag_1 = np.ones(N - 1) + diag_1[(N2 - 1)::N2] = 0 + stride = N2 + diag_2 = np.ones((N - stride,)) W = sparse.diags(diagonals=[diag_1, diag_2], offsets=[-1, -stride], - shape=(h * w, h * w), + shape=(N, N), format='csr', dtype='float') W = utils.symmetrize(W, symmetrize_type='full') - x = np.kron(np.ones((h, 1)), (np.arange(w) / float(w)).reshape(w, 1)) - y = np.kron(np.ones((w, 1)), np.arange(h) / float(h)).reshape(h * w, 1) + x = np.kron(np.ones((N1, 1)), (np.arange(N2)/float(N2)).reshape(N2, 1)) + y = np.kron(np.ones((N2, 1)), np.arange(N1)/float(N1)).reshape(N, 1) y = np.sort(y, axis=0)[::-1] coords = np.concatenate((x, y), axis=1) - self.h = h - self.w = w - plotting = {"limits": np.array([-1. / self.w, 1 + 1. / self.w, - 1. / self.h, 1 + 1. / self.h])} + plotting = {"limits": np.array([-1. / N2, 1 + 1. / N2, + 1. / N1, 1 + 1. / N1])} super(Grid2d, self).__init__(W=W, gtype='2d-grid', coords=coords, plotting=plotting, **kwargs) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 4e3eb2b7..8cafb961 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -224,12 +224,8 @@ def test_swissroll(self): graphs.SwissRoll(dim=3) def test_grid2d(self): - G = graphs.Grid2d(shape=(3, 2)) - self.assertEqual([G.h, G.w], [3, 2]) - G = graphs.Grid2d(shape=(3,)) - self.assertEqual([G.h, G.w], [3, 3]) - G = graphs.Grid2d(shape=3) - self.assertEqual([G.h, G.w], [3, 3]) + graphs.Grid2d(3, 2) + graphs.Grid2d(3) def test_imgpatches(self): graphs.ImgPatches(img=self._img, patch_shape=(3, 3)) From bfe04b1b36d52d43e2ccce8af675ecf83d9b941b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 06:00:05 +0200 Subject: [PATCH 260/392] doc: plot all embedded graphs --- pygsp/graphs/airfoil.py | 3 ++- pygsp/graphs/barabasialbert.py | 2 +- pygsp/graphs/comet.py | 33 ++++++++++++----------- pygsp/graphs/community.py | 3 ++- pygsp/graphs/davidsensornet.py | 5 ++-- pygsp/graphs/erdosrenyi.py | 2 +- pygsp/graphs/fullconnected.py | 7 +++-- pygsp/graphs/grid2d.py | 3 ++- pygsp/graphs/logo.py | 3 ++- pygsp/graphs/lowstretchtree.py | 20 +++++++++----- pygsp/graphs/minnesota.py | 6 +++-- pygsp/graphs/nngraphs/bunny.py | 15 +++++++---- pygsp/graphs/nngraphs/cube.py | 15 +++++++++-- pygsp/graphs/nngraphs/grid2dimgpatches.py | 5 ++-- pygsp/graphs/nngraphs/nngraph.py | 5 ++-- pygsp/graphs/nngraphs/sphere.py | 12 +++++++-- pygsp/graphs/nngraphs/twomoons.py | 15 ++++++----- pygsp/graphs/path.py | 5 ++-- pygsp/graphs/randomring.py | 3 ++- pygsp/graphs/ring.py | 3 ++- pygsp/graphs/sensor.py | 3 ++- pygsp/graphs/stochasticblockmodel.py | 2 +- pygsp/graphs/swissroll.py | 11 ++++++-- pygsp/graphs/torus.py | 17 +++++++----- 24 files changed, 127 insertions(+), 71 deletions(-) diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 375bde89..01da2b0c 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -12,7 +12,8 @@ class Airfoil(Graph): Examples -------- - >>> G = graphs.Airfoil() + >>> import matplotlib + >>> graphs.Airfoil().plot() """ diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index e3407941..959bebc1 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -31,7 +31,7 @@ class BarabasiAlbert(Graph): Examples -------- - >>> G = graphs.BarabasiAlbert(500) + >>> G = graphs.BarabasiAlbert() """ def __init__(self, N=1000, m0=1, m=1, **kwargs): diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index fc4b3daa..623e7359 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -9,39 +9,42 @@ class Comet(Graph): r"""Comet graph. + The comet graph is a path graph with a star of degree *k* at its end. + Parameters ---------- - Nv : int - Number of vertices along the first dimension (default is 16) - Mv : int - Number of vertices along the second dimension (default is Nv) + N : int + Number of nodes. + k : int + Degree of center vertex. Examples -------- - >>> G = graphs.Comet() + >>> import matplotlib + >>> graphs.Comet(20, 10).plot() """ - def __init__(self, Nv=32, k=12, **kwargs): + def __init__(self, N=32, k=12, **kwargs): - # Create weighted adjancency matrix + # Create weighted adjacency matrix i_inds = np.concatenate((np.zeros((k)), np.arange(k) + 1, - np.arange(k, Nv - 1), - np.arange(k + 1, Nv))) + np.arange(k, N - 1), + np.arange(k + 1, N))) j_inds = np.concatenate((np.arange(k) + 1, np.zeros((k)), - np.arange(k + 1, Nv), - np.arange(k, Nv - 1))) + np.arange(k + 1, N), + np.arange(k, N - 1))) W = sparse.csc_matrix((np.ones(np.size(i_inds)), (i_inds, j_inds)), - shape=(Nv, Nv)) + shape=(N, N)) - tmpcoords = np.zeros((Nv, 2)) + tmpcoords = np.zeros((N, 2)) inds = np.arange(k) + 1 tmpcoords[1:k + 1, 0] = np.cos(inds*2*np.pi/k) tmpcoords[1:k + 1, 1] = np.sin(inds*2*np.pi/k) - tmpcoords[k + 1:, 0] = np.arange(1, Nv - k) + 1 + tmpcoords[k + 1:, 0] = np.arange(1, N - k) + 1 - self.Nv = Nv + self.N = N self.k = k plotting = {"limits": np.array([-2, np.max(tmpcoords[:, 0]), diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 011c29c9..b656d894 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -39,7 +39,8 @@ class Community(Graph): Examples -------- - >>> G = graphs.Community() + >>> import matplotlib + >>> graphs.Community().plot() """ def __init__(self, N=256, **kwargs): diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index cb95d620..dbd7ed0f 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -18,9 +18,8 @@ class DavidSensorNet(Graph): Examples -------- - >>> G = graphs.DavidSensorNet(N=64) - >>> G = graphs.DavidSensorNet(N=500) - >>> G = graphs.DavidSensorNet(N=123) + >>> import matplotlib + >>> graphs.DavidSensorNet().plot() """ diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index b6712d92..0e03f5cf 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -28,7 +28,7 @@ class ErdosRenyi(Graph): Examples -------- - >>> G = graphs.ErdosRenyi(100, 0.05) + >>> G = graphs.ErdosRenyi() """ diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index c781731d..a938795e 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -15,15 +15,14 @@ class FullConnected(Graph): Examples -------- - >>> G = graphs.FullConnected(N=5) + >>> G = graphs.FullConnected() """ def __init__(self, N=10): - tmp = np.arange(N).reshape(N, 1) - W = np.ones((N, N)) - np.identity(N) plotting = {'limits': np.array([-1, 1, -1, 1])} - super(FullConnected, self).__init__(W=W, gtype='full', plotting=plotting) + super(FullConnected, self).__init__(W=W, gtype='full', + plotting=plotting) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 25c85cf3..5e8782c7 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,7 +19,8 @@ class Grid2d(Graph): Examples -------- - >>> G = graphs.Grid2d(shape=(32,)) + >>> import matplotlib + >>> graphs.Grid2d().plot() """ diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 6aa02a21..6bd99a54 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -11,7 +11,8 @@ class Logo(Graph): Examples -------- - >>> G = graphs.Logo() + >>> import matplotlib + >>> graphs.Logo().plot() """ diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index b92327b9..988fb394 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -7,16 +7,21 @@ class LowStretchTree(Graph): - r"""Low stretch tree graph. + r"""Low stretch tree. + + Build the root of a low stretch tree on a grid of points. There are + :math:`2k` points on each side of the grid, and therefore :math:`2^{2k}` + vertices in total. The edge weights are all equal to 1. Parameters ---------- k : int - 2^k points on each side of the grid of vertices (default 6) + :math:`2^k` points on each side of the grid of vertices. Examples -------- - >>> G = graphs.LowStretchTree(k=3) + >>> import matplotlib + >>> graphs.LowStretchTree(k=3).plot() """ @@ -54,8 +59,11 @@ def __init__(self, k=6, **kwargs): self.root = 4**(k - 1) plotting = {"edges_width": 1.25, - "vertex_sizee": 75, + "vertex_size": 75, "limits": np.array([0, 2**k + 1, 0, 2**k + 1])} - super(LowStretchTree, self).__init__(W=W, coords=coords, plotting=plotting, - gtype="low strech tree", **kwargs) + super(LowStretchTree, self).__init__(W=W, + coords=coords, + plotting=plotting, + gtype="low stretch tree", + **kwargs) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 462ba9fd..8f875b05 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -23,7 +23,9 @@ class Minnesota(Graph): Examples -------- - >>> G = graphs.Minnesota() + >>> import matplotlib.pyplot as plt + >>> fig, ax = plt.subplots(figsize=(7, 5)) + >>> graphs.Minnesota().plot(ax=ax) """ @@ -34,7 +36,7 @@ def __init__(self, connect=True): A = data['A'] plotting = {"limits": np.array([-98, -89, 43, 50]), - "vertex_size": 30} + "vertex_size": 40} if connect: diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 82cbbdc3..7c537f87 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -13,7 +13,10 @@ class Bunny(NNGraph): Examples -------- - >>> G = graphs.Bunny() + >>> import matplotlib.pyplot as plt + >>> fig = plt.figure(figsize=(10, 8)) + >>> ax = fig.add_subplot(111, projection='3d') + >>> graphs.Bunny().plot(ax=ax) """ @@ -21,10 +24,12 @@ def __init__(self, **kwargs): data = utils.loadmat('pointclouds/bunny') - plotting = {'vertex_size': 10, - 'elevation': -89, - 'azimuth': 94, - 'distance': 7} + plotting = { + 'vertex_size': 10, + 'elevation': -90, + 'azimuth': 90, + 'distance': 7, + } super(Bunny, self).__init__(Xin=data['bunny'], epsilon=0.2, NNtype='radius', plotting=plotting, diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 03aaeeb5..e982155f 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -22,7 +22,10 @@ class Cube(NNGraph): Examples -------- - >>> G = graphs.Cube(radius=5) + >>> import matplotlib.pyplot as plt + >>> fig = plt.figure(figsize=(10, 8)) + >>> ax = fig.add_subplot(111, projection='3d') + >>> graphs.Cube().plot(ax=ax) """ @@ -65,4 +68,12 @@ def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling="random", **kwargs): else: raise ValueError("Unknown sampling !") - super(Cube, self).__init__(Xin=pts, k=10, gtype="Cube", **kwargs) + plotting = { + 'vertex_size': 80, + 'elevation': 15, + 'azimuth': 0, + 'distance': 7, + } + + super(Cube, self).__init__(Xin=pts, k=10, gtype="Cube", + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index ce6ea6ac..027d0336 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -24,15 +24,16 @@ class Grid2dImgPatches(Graph): Examples -------- + >>> import matplotlib >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::32, ::32]) - >>> G = graphs.Grid2dImgPatches(img) + >>> graphs.Grid2dImgPatches(img).plot() """ def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): - Gg = Grid2d(shape=img.shape) + Gg = Grid2d(*img.shape) Gp = ImgPatches(img=img, patch_shape=patch_shape, n_nbrs=n_nbrs) gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 52e4c550..ae4fa3fe 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -63,8 +63,9 @@ class NNGraph(Graph): Examples -------- - >>> Xin = np.arange(90).reshape(30, 3) - >>> G = graphs.NNGraph(Xin) + >>> import matplotlib + >>> X = np.random.uniform(size=(30, 2)) + >>> graphs.NNGraph(X).plot() """ diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index d1370eca..e1218bcd 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -22,7 +22,10 @@ class Sphere(NNGraph): Examples -------- - >>> G = graphs.Sphere(radius=5) + >>> import matplotlib.pyplot as plt + >>> fig = plt.figure(figsize=(10, 8)) + >>> ax = fig.add_subplot(111, projection='3d') + >>> graphs.Sphere().plot(ax=ax) """ @@ -40,4 +43,9 @@ def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling='random', **kwargs): else: raise ValueError('Unknow sampling!') - super(Sphere, self).__init__(Xin=pts, gtype='Sphere', k=10, **kwargs) + plotting = { + 'vertex_size': 80, + } + + super(Sphere, self).__init__(Xin=pts, gtype='Sphere', k=10, + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 90a2cbcf..f55e57d3 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -34,12 +34,8 @@ class TwoMoons(NNGraph): Examples -------- - >>> G = graphs.TwoMoons(moontype='standard', dim=4) - >>> G.coords.shape - (2000, 4) - >>> G = graphs.TwoMoons(moontype='synthesized', N=1000, sigmad=0.1, d=1) - >>> G.coords.shape - (1000, 2) + >>> import matplotlib + >>> graphs.TwoMoons().plot() """ @@ -86,4 +82,9 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, self.labels = np.concatenate((np.zeros(N1), np.ones(N2))) - super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, gtype=gtype) + plotting = { + 'vertex_size': 5, + } + + super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, + plotting=plotting, gtype=gtype) diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index a31a7267..001b222a 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -12,11 +12,12 @@ class Path(Graph): Parameters ---------- N : int - Number of vertices (default = 32) + Number of vertices. Examples -------- - >>> G = graphs.Path(N=16) + >>> import matplotlib + >>> graphs.Path().plot() References ---------- diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index a58a388d..e7f1733d 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -18,7 +18,8 @@ class RandomRing(Graph): Examples -------- - >>> G = graphs.RandomRing(N=16) + >>> import matplotlib + >>> graphs.RandomRing().plot() """ diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 7839d189..372e700a 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -18,7 +18,8 @@ class Ring(Graph): Examples -------- - >>> G = graphs.Ring() + >>> import matplotlib + >>> graphs.Ring().plot() """ diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index e7848abd..6166f2fe 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -29,7 +29,8 @@ class Sensor(Graph): Examples -------- - >>> G = graphs.Sensor(N=300) + >>> import matplotlib + >>> graphs.Sensor().plot() """ diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index f8934f08..52327dd7 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -41,7 +41,7 @@ class StochasticBlockModel(Graph): Examples -------- - >>> G = graphs.StochasticBlockModel(N=1024, k=5) + >>> G = graphs.StochasticBlockModel() """ diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 0110cd09..03d75ae8 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -31,7 +31,8 @@ class SwissRoll(Graph): Examples -------- - >>> G = graphs.SwissRoll() + >>> import matplotlib + >>> graphs.SwissRoll().plot() """ @@ -67,7 +68,13 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, W -= np.diag(np.diag(W)) W[W < thresh] = 0 - plotting = {'limits': np.array([-1, 1, -1, 1, -1, 1])} + plotting = { + 'vertex_size': 60, + 'limits': np.array([-1, 1, -1, 1, -1, 1]), + 'elevation': 15, + 'azimuth': -90, + 'distance': 7, + } gtype = 'swiss roll {}'.format(srtype) super(SwissRoll, self).__init__(W=W, coords=coords.T, diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 97121e62..26665813 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -16,19 +16,20 @@ class Torus(Graph): Mv : int Number of vertices along the second dimension (default is Nv) - Examples - -------- - >>> G = graphs.Torus(Nv=32) - References ---------- See :cite:`strang1999discrete` for more informations. + Examples + -------- + >>> import matplotlib + >>> graphs.Torus().plot() + """ def __init__(self, Nv=16, Mv=None, **kwargs): - if not Mv: + if Mv is None: Mv = Nv # Create weighted adjancency matrix @@ -79,8 +80,10 @@ def __init__(self, Nv=16, Mv=None, **kwargs): self.Nv = Nv self.Mv = Nv - plotting = {"vertex_size": 30, - "limits": np.array([-2.5, 2.5, -2.5, 2.5, -2.5, 2.5])} + plotting = { + 'vertex_size': 60, + 'limits': np.array([-2.5, 2.5, -2.5, 2.5, -2.5, 2.5]) + } super(Torus, self).__init__(W=W, gtype='Torus', coords=coords, plotting=plotting, **kwargs) From e9ca6064aead22e141df9414c053f5c18cef5e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 30 Aug 2017 15:54:36 +0200 Subject: [PATCH 261/392] airfoil graph: show edges --- pygsp/graphs/airfoil.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 01da2b0c..34af6072 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -12,8 +12,9 @@ class Airfoil(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Airfoil().plot() + >>> import matplotlib.pyplot as plt + >>> fig, ax = plt.subplots(figsize=(7, 5)) + >>> graphs.Airfoil().plot(show_edges=True, ax=ax) """ From b03041d9139ad40c7d3644f2d26e3573f389e305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 31 Aug 2017 16:01:25 +0200 Subject: [PATCH 262/392] filter.evaluate: return ndarray instead of list --- pygsp/filters/filter.py | 25 ++++++++++++------------- pygsp/plotting.py | 15 ++++----------- pygsp/tests/test_filters.py | 2 +- 3 files changed, 17 insertions(+), 25 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 75975f13..9a727f3f 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -145,7 +145,7 @@ def analysis(self, s, method='chebyshev', order=30): c = np.zeros((N * self.Nf)) is2d = False - fie = self.evaluate(self.G.e) + fie = self.evaluate(self.G.e).squeeze() if self.Nf == 1: if is2d: @@ -170,37 +170,38 @@ def analysis(self, s, method='chebyshev', order=30): return c - @utils.filterbank_handler - def evaluate(self, x, i=0): + def evaluate(self, x): r"""Evaluate the kernels at given frequencies. Parameters ---------- x : ndarray Graph frequencies at which to evaluate the filter. - i : int - Index of the filter to evaluate. Default: evaluate all of them. Returns ------- y : ndarray - Responses of the filters. + Frequency response of the filters. Shape ``(G.Nf, len(x))``. Examples -------- - Frequency response of a low-pass filter. + Frequency response of a low-pass filter: >>> import matplotlib.pyplot as plt >>> G = graphs.Logo() + >>> G.compute_fourier_basis() >>> f = filters.Expwin(G) >>> G.compute_fourier_basis() >>> y = f.evaluate(G.e) - >>> plt.plot(G.e, y) # doctest: +ELLIPSIS + >>> plt.plot(G.e, y[0]) # doctest: +ELLIPSIS [] """ - - return self.g[i](x) + # Avoid to copy data as with np.array([g(x) for g in self.g]). + y = np.empty((self.Nf, len(x))) + for i, g in enumerate(self.g): + y[i] = g(x) + return y def inverse(self, c): r""" @@ -366,7 +367,6 @@ def localize(self, i, **kwargs): >>> G.plot_signal(s) """ - s = np.zeros(self.G.N) s[i] = 1 return np.sqrt(self.G.N) * self.analysis(s, **kwargs) @@ -445,7 +445,6 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, A=1.000, B=1.000 """ - if max is None: max = self.G.lmax @@ -454,7 +453,7 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, else: x = np.linspace(min, max, N) - sum_filters = np.sum(np.abs(np.power(self.evaluate(x), 2)), axis=0) + sum_filters = np.sum(np.abs(self.evaluate(x)**2), axis=0) return sum_filters.min(), sum_filters.max() diff --git a/pygsp/plotting.py b/pygsp/plotting.py index cdd327f8..f246e3a1 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -379,15 +379,9 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, if show_sum is None: show_sum = filters.Nf > 1 - lambdas = np.linspace(0, G.lmax, npoints) - - fd = filters.evaluate(lambdas) - - if filters.Nf == 1: - ax.plot(lambdas, fd, linewidth=line_width) - else: - for fd_i in fd: - ax.plot(lambdas, fd_i, linewidth=line_width) + x = np.linspace(0, G.lmax, npoints) + y = filters.evaluate(x).T + ax.plot(x, y, linewidth=line_width) if plot_eigenvalues: ax.plot(G.e, np.zeros(G.N), 'xk', markeredgewidth=x_width, @@ -396,8 +390,7 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, # TODO: plot highlighted eigenvalues if show_sum: - test_sum = np.sum(np.power(fd, 2), 0) - ax.plot(lambdas, test_sum, 'k', linewidth=line_width) + ax.plot(x, np.sum(y**2, 1), 'k', linewidth=line_width) ax.set_xlabel("laplacian's eigenvalues / graph frequencies") ax.set_ylabel('filter response') diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 01d7880d..791c09fb 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -81,7 +81,7 @@ def test_localize(self): s1 = g.localize(NODE, method='exact') # Should be equal to a row / column of the filtering operator. - gL = G.U.dot(np.diag(g.evaluate(G.e)).dot(G.U.T)) + gL = G.U.dot(np.diag(g.evaluate(G.e)[0]).dot(G.U.T)) s2 = np.sqrt(G.N) * gL[NODE, :] np.testing.assert_allclose(s1, s2) From c82bf38f6bddc1b3c1a0597a117e681f29d44d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 10:04:14 +0200 Subject: [PATCH 263/392] test tight frames --- pygsp/filters/held.py | 2 +- pygsp/filters/papadakis.py | 2 +- pygsp/filters/regular.py | 2 +- pygsp/filters/simoncelli.py | 2 +- pygsp/tests/test_filters.py | 55 +++++++++++++++++++------------------ 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index 604af7aa..9006bbee 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -6,7 +6,7 @@ class Held(Filter): - r"""Design 2 filters with the Held construction. + r"""Design 2 filters with the Held construction (tight frame). This function create a parseval filterbank of :math:`2` filters. The low-pass filter is defined by the function diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index be48f1df..3cc68d4d 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -6,7 +6,7 @@ class Papadakis(Filter): - r"""Design 2 filters with the Papadakis construction. + r"""Design 2 filters with the Papadakis construction (tight frame). This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by the function diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index fba9a5ca..d4b15d2c 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -6,7 +6,7 @@ class Regular(Filter): - r"""Design 2 filters with the regular construction. + r"""Design 2 filters with the regular construction (tight frame). This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by a function :math:`f_l(x)` diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index b3578787..ee6aa881 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -6,7 +6,7 @@ class Simoncelli(Filter): - r"""Design 2 filters with the Simoncelli construction. + r"""Design 2 filters with the Simoncelli construction (tight frame). This function creates a Parseval filter bank of 2 filters. The low-pass filter is defined by the function diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 791c09fb..5f7656a3 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -40,7 +40,7 @@ def _test_synthesis(self, f): self.assertRaises(NotImplementedError, f.synthesis, S, method='lanczos') - def _test_methods(self, f): + def _test_methods(self, f, tight): self.assertIs(f.G, self._G) c_exact = f.analysis(self._signal, method='exact') @@ -62,8 +62,11 @@ def _test_methods(self, f): self._test_synthesis(f) f.evaluate(self._G.e) - # Minimum is not 0 to avoid division by 0 in expwin. - f.estimate_frame_bounds(min=0.01) + A, B = f.estimate_frame_bounds(use_eigenvalues=True) + if tight: + np.testing.assert_allclose(A, B) + else: + assert B - A > 0.01 # TODO: f.can_dual() @@ -95,80 +98,80 @@ def kernel(x): f = filters.Filter(self._G, kernels=kernel) self.assertEqual(f.Nf, 1) self.assertIs(f.g[0], kernel) - self._test_methods(f) + self._test_methods(f, tight=False) def test_abspline(self): f = filters.Abspline(self._G, Nf=4) - self._test_methods(f) + self._test_methods(f, tight=False) def test_gabor(self): f = filters.Gabor(self._G, lambda x: x / (1. + x)) - self._test_methods(f) + self._test_methods(f, tight=False) def test_halfcosine(self): f = filters.HalfCosine(self._G, Nf=4) - self._test_methods(f) + self._test_methods(f, tight=True) def test_itersine(self): f = filters.Itersine(self._G, Nf=4) - self._test_methods(f) + self._test_methods(f, tight=True) def test_mexicanhat(self): f = filters.MexicanHat(self._G, Nf=5, normalize=False) - self._test_methods(f) + self._test_methods(f, tight=False) f = filters.MexicanHat(self._G, Nf=4, normalize=True) - self._test_methods(f) + self._test_methods(f, tight=False) def test_meyer(self): f = filters.Meyer(self._G, Nf=4) - self._test_methods(f) + self._test_methods(f, tight=True) def test_simpletf(self): f = filters.SimpleTight(self._G, Nf=4) - self._test_methods(f) + self._test_methods(f, tight=True) def test_regular(self): f = filters.Regular(self._G) - self._test_methods(f) + self._test_methods(f, tight=True) f = filters.Regular(self._G, d=5) - self._test_methods(f) + self._test_methods(f, tight=True) f = filters.Regular(self._G, d=0) - self._test_methods(f) + self._test_methods(f, tight=True) def test_held(self): f = filters.Held(self._G) - self._test_methods(f) + self._test_methods(f, tight=True) f = filters.Held(self._G, a=0.25) - self._test_methods(f) + self._test_methods(f, tight=True) def test_simoncelli(self): f = filters.Simoncelli(self._G) - self._test_methods(f) + self._test_methods(f, tight=True) f = filters.Simoncelli(self._G, a=0.25) - self._test_methods(f) + self._test_methods(f, tight=True) def test_papadakis(self): f = filters.Papadakis(self._G) - self._test_methods(f) + self._test_methods(f, tight=True) f = filters.Papadakis(self._G, a=0.25) - self._test_methods(f) + self._test_methods(f, tight=True) def test_heat(self): f = filters.Heat(self._G, normalize=False, tau=10) - self._test_methods(f) + self._test_methods(f, tight=False) f = filters.Heat(self._G, normalize=False, tau=np.array([5, 10])) - self._test_methods(f) + self._test_methods(f, tight=False) f = filters.Heat(self._G, normalize=True, tau=10) np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)), 1) - self._test_methods(f) + self._test_methods(f, tight=False) f = filters.Heat(self._G, normalize=True, tau=[5, 10]) np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)[0]), 1) np.testing.assert_allclose(np.linalg.norm(f.evaluate(self._G.e)[1]), 1) - self._test_methods(f) + self._test_methods(f, tight=False) def test_expwin(self): f = filters.Expwin(self._G) - self._test_methods(f) + self._test_methods(f, tight=False) def test_approximations(self): r""" From 849f949813c08752664e919bab41718561c96fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 12:19:06 +0200 Subject: [PATCH 264/392] Filter.filter: generalize analysis and synthesis Standardize signal shapes as N_NODES x N_SIGNALS x N_FEATURES --- pygsp/filters/__init__.py | 1 + pygsp/filters/filter.py | 171 ++++++++++++++++++++++++++++++++++++ pygsp/graphs/__init__.py | 1 + pygsp/graphs/fourier.py | 9 ++ pygsp/graphs/graph.py | 51 +++++++++++ pygsp/tests/test_filters.py | 21 +++++ pygsp/tests/test_graphs.py | 17 ++++ 7 files changed, 271 insertions(+) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 303669dc..dfd0dc7e 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -16,6 +16,7 @@ .. autosummary:: Filter.evaluate + Filter.filter Filter.analysis Filter.synthesis Filter.compute_frame diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 9a727f3f..1c9fa997 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -203,6 +203,177 @@ def evaluate(self, x): y[i] = g(x) return y + def filter(self, s, method='chebyshev', order=30): + r""" + Filter signals with the filter bank (analysis or synthesis). + + A signal is defined as a rank-3 tensor of shape ``(N_NODES, N_SIGNALS, + N_FEATURES)``, where ``N_NODES`` is the number of nodes in the graph, + ``N_SIGNALS`` is the number of independent signals, and ``N_FEATURES`` + is the number of features which compose a graph signal, or the + dimensionality of a graph signal. For example if you filter a signal + with a filter bank of 8 filters, you're extracting 8 features and + decomposing your signal into 8 parts. That is called analysis. Your are + thus transforming your signal tensor from ``(G.N, 1, 1)`` to ``(G.N, 1, + 8)``. Now you may want to combine back the features to form an unique + signal. For this you apply again 8 filters, one filter per feature, and + sum the result up. As such you're transforming your ``(G.N, 1, 8)`` + tensor signal back to ``(G.N, 1, 1)``. That is known as synthesis. More + generally, you may want to map a set of features to another, though + that is not implemented yet. + + The method computes the transform coefficients of a signal :math:`s`, + where the atoms of the transform dictionary are generalized + translations of each graph spectral filter to each vertex on the graph: + + .. math:: c = D^* s, + + where the columns of :math:`D` are :math:`g_{i,m} = T_i g_m` and + :math:`T_i` is a generalized translation operator applied to each + filter :math:`\hat{g}_m(\cdot)`. Each column of :math:`c` is the + response of the signal to one filter. + + In other words, this function is applying the analysis operator + :math:`D^*`, respectively the synthesis operator :math:`D`, associated + with the frame defined by the filter bank to the signals. + + Parameters + ---------- + s : ndarray + 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 + filter, and ``N_FEATURES`` is either 1 (analysis) or the number of + filters in the filter bank (synthesis). + method : {'exact', 'chebyshev'} + Whether to use the exact method (via the graph Fourier transform) + or the Chebyshev polynomial approximation. A Lanczos + approximation is coming. + order : int + Degree of the Chebyshev polynomials. + + Returns + ------- + s : ndarray + Graph signals, a tensor of shape ``(N_NODES, N_SIGNALS, + N_FEATURES)``, where ``N_NODES`` and ``N_SIGNALS`` are the number + of nodes and signals of the signal tensor that pas passed in, and + ``N_FEATURES`` is either 1 (synthesis) or the number of filters in + the filter bank (analysis). + + References + ---------- + See :cite:`hammond2011wavelets` for details on filtering graph signals. + + Examples + -------- + + Create a bunch of smooth signals by low-pass filtering white noise: + + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=60) + >>> G.estimate_lmax() + >>> s = np.random.RandomState(42).uniform(size=(G.N, 10)) + >>> taus = [1, 10, 100] + >>> s = filters.Heat(G, taus).filter(s) + >>> s.shape + (60, 10, 3) + + Plot the 3 smoothed versions of the 10th signal: + + >>> fig, ax = plt.subplots() + >>> G.set_coordinates('line1D') # To visualize multiple signals in 1D. + >>> G.plot_signal(s[:, 9, :], ax=ax) + >>> legend = [r'$\tau={}$'.format(t) for t in taus] + >>> ax.legend(legend) # doctest: +ELLIPSIS + + + Low-pass filter a delta to create a localized smooth signal: + + >>> G = graphs.Sensor(30, seed=42) + >>> G.compute_fourier_basis() # Reproducible computation of lmax. + >>> s1 = np.zeros(G.N) + >>> s1[13] = 1 + >>> s1 = filters.Heat(G, 3).filter(s1) + >>> s1.shape + (30, 1, 1) + + Filter and reconstruct our signal: + + >>> g = filters.MexicanHat(G, Nf=4) + >>> s2 = g.filter(s1) + >>> s2.shape + (30, 1, 4) + >>> s2 = g.filter(s2) + >>> s2.shape + (30, 1, 1) + + Look how well we were able to reconstruct: + + >>> fig, axes = plt.subplots(1, 2) + >>> G.plot_signal(s1, ax=axes[0]) + >>> G.plot_signal(s2, ax=axes[1]) + >>> print('{:.5f}'.format(np.linalg.norm(s1 - s2))) + 0.29620 + + Perfect reconstruction with Itersine, a tight frame: + + >>> g = filters.Itersine(G) + >>> s2 = g.filter(s1, method='exact') + >>> s2 = g.filter(s2, method='exact') + >>> np.linalg.norm(s1 - s2) < 1e-10 + True + + """ + s = self.G.sanitize_signal(s) + N_NODES, N_SIGNALS, N_FEATURES_IN = s.shape + + # TODO: generalize to 2D (m --> n) filter banks. + # Only 1 --> Nf (analysis) and Nf --> 1 (synthesis) for now. + if N_FEATURES_IN not in [1, self.Nf]: + raise ValueError('Last dimension (N_FEATURES) should either be ' + '1 or the number of filters (Nf), ' + 'not {}.'.format(s.shape)) + N_FEATURES_OUT = self.Nf if N_FEATURES_IN == 1 else 1 + + if method == 'exact': + + axis = 1 if N_FEATURES_IN == 1 else 2 + f = self.evaluate(self.G.e) + f = np.expand_dims(f.T, axis) + assert f.shape == (N_NODES, N_FEATURES_IN, N_FEATURES_OUT) + + s = self.G.gft2(s) + s = np.matmul(s, f) + s = self.G.igft2(s) + + elif method == 'chebyshev': + + # TODO: update Chebyshev implementation (after 2D filter banks). + c = approximations.compute_cheby_coeff(self, m=order) + + if N_FEATURES_IN == 1: # Analysis. + s = s.squeeze(axis=2) + s = approximations.cheby_op(self.G, c, s) + s = s.reshape((N_NODES, N_FEATURES_OUT, N_SIGNALS), order='F') + s = s.swapaxes(1, 2) + + elif N_FEATURES_IN == self.Nf: # Synthesis. + s = s.swapaxes(1, 2) + s_in = s.reshape((N_NODES*N_FEATURES_IN, N_SIGNALS), order='F') + s = np.zeros((N_NODES, N_SIGNALS)) + tmpN = np.arange(N_NODES, dtype=int) + for i in range(N_FEATURES_IN): + s += approximations.cheby_op(self.G, + c[i], + s_in[i * N_NODES + tmpN]) + s = np.expand_dims(s, 2) + + else: + raise ValueError('Unknown method {}.'.format(method)) + + return s + def inverse(self, c): r""" Not implemented yet. diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index 891efc3b..a844a74f 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -86,6 +86,7 @@ Graph.set_coordinates Graph.subgraph Graph.extract_components + Graph.sanitize_signal Graph models ============ diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 1f3c798c..5c06d12b 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -139,6 +139,11 @@ def gft(self, s): """ return np.dot(np.conjugate(self.U.T), s) # True Hermitian here. + def gft2(self, s): + s = self.sanitize_signal(s) + U = np.conjugate(self.U) # True Hermitian. (Although U is often real.) + return np.tensordot(U, s, ([0], [0])) + def igft(self, s_hat): r"""Compute the inverse graph Fourier transform. @@ -171,6 +176,10 @@ def igft(self, s_hat): """ return np.dot(self.U, s_hat) + def igft2(self, s_hat): + s_hat = self.sanitize_signal(s_hat) + return np.tensordot(self.U, s_hat, ([1], [0])) + def translate(self, f, i): r"""Translate the signal *f* to the node *i*. diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index c404c157..077a59b9 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -725,6 +725,57 @@ def get_edge_list(self): return v_in, v_out, weights + def sanitize_signal(self, s): + r"""Standardize signal shape. + + Add singleton dimensions at the end and check the resulting shape. + + Parameters + ---------- + s : ndarray + Signal tensor of shape ``(N_NODES)``, ``(N_NODES, N_SIGNALS)``, or + ``(N_NODES, N_SIGNALS, N_FEATURES)``. + + Returns + ------- + s : ndarray + Signal tensor of shape ``(N_NODES, N_SIGNALS, N_FEATURES)``. + + Raises + ------ + ValueError + If the passed signal tensor is more than 3 dimensions or if the + first dimension's size is not the number of nodes. + + Examples + -------- + >>> G = graphs.Logo() + >>> s = np.ones(G.N) # One signal, one feature. + >>> G.sanitize_signal(s).shape + (1130, 1, 1) + >>> s = np.ones((G.N, 10)) # Ten signals of one feature. + >>> G.sanitize_signal(s).shape + (1130, 10, 1) + >>> s = np.ones((G.N, 10, 5)) # Ten signals of 5 features. + >>> G.sanitize_signal(s).shape + (1130, 10, 5) + + """ + if s.ndim == 1: + # Single signal, single feature. + s = np.expand_dims(s, axis=1) + + if s.ndim == 2: + # Multiple signals, single feature. + s = np.expand_dims(s, axis=2) + + if s.ndim != 3 or s.shape[0] != self.N: + raise ValueError('Signal must have shape N_NODES x N_SIGNALS x ' + 'N_FEATURES, not {}. Last singleton dimensions ' + 'may be omitted.'.format(s.shape)) + + return s + def modulate(self, f, k): r"""Modulate the signal *f* to the frequency *k*. diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 5f7656a3..3e05ad2d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -40,9 +40,30 @@ def _test_synthesis(self, f): self.assertRaises(NotImplementedError, f.synthesis, S, method='lanczos') + def _test_filter(self, f, tight): + # Analysis. + s2 = f.filter(self._signal, method='exact') + s3 = f.filter(self._signal, method='chebyshev', order=100) + + # Synthesis. + s4 = f.filter(s2, method='exact') + s5 = f.filter(s3, method='chebyshev', order=100) + + if f.Nf < 100: + # TODO: does not pass for Gabor. + np.testing.assert_allclose(s2, s3, rtol=0.1, atol=0.01) + np.testing.assert_allclose(s4, s5, rtol=0.1, atol=0.01) + + if tight: + A, _ = f.estimate_frame_bounds(use_eigenvalues=True) + np.testing.assert_allclose(s4.squeeze(), A * self._signal) + assert np.linalg.norm(s5.squeeze() - A * self._signal) < 0.1 + def _test_methods(self, f, tight): self.assertIs(f.G, self._G) + self._test_filter(f, tight) + c_exact = f.analysis(self._signal, method='exact') c_cheby = f.analysis(self._signal, method='chebyshev') self.assertEqual(c_exact.shape, c_cheby.shape) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 8cafb961..0855f512 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -119,6 +119,23 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') + def test_sanitize_signal(self): + s1 = np.arange(self._G.N) + s2 = np.reshape(s1, (self._G.N, 1)) + s3 = np.reshape(s1, (self._G.N, 1, 1)) + s4 = np.arange(self._G.N*10).reshape((self._G.N, 10)) + s5 = np.reshape(s4, (self._G.N, 10, 1)) + s1 = self._G.sanitize_signal(s1) + s2 = self._G.sanitize_signal(s2) + s3 = self._G.sanitize_signal(s3) + s4 = self._G.sanitize_signal(s4) + s5 = self._G.sanitize_signal(s5) + np.testing.assert_equal(s2, s1) + np.testing.assert_equal(s3, s1) + np.testing.assert_equal(s5, s4) + self.assertRaises(ValueError, self._G.sanitize_signal, + np.ones((2, 2, 2, 2))) + def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] From 2e63e58ed5521b60818e9563a66e4cbb745b6e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 13:32:11 +0200 Subject: [PATCH 265/392] convert compute_frame and localize to filter --- pygsp/filters/abspline.py | 1 - pygsp/filters/expwin.py | 1 - pygsp/filters/filter.py | 43 ++++++++++++++++-------------------- pygsp/filters/halfcosine.py | 1 - pygsp/filters/heat.py | 1 - pygsp/filters/held.py | 1 - pygsp/filters/itersine.py | 1 - pygsp/filters/mexicanhat.py | 1 - pygsp/filters/meyer.py | 1 - pygsp/filters/papadakis.py | 1 - pygsp/filters/regular.py | 1 - pygsp/filters/simoncelli.py | 1 - pygsp/filters/simpletight.py | 1 - pygsp/tests/test_filters.py | 16 ++++++++------ 14 files changed, 28 insertions(+), 43 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index b5a4a04c..11a81c0e 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -34,7 +34,6 @@ class Abspline(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Abspline(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 4ae131ee..29f789a5 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -27,7 +27,6 @@ class Expwin(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Expwin(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 1c9fa997..e23ed85f 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -528,8 +528,9 @@ def localize(self, i, **kwargs): Examples -------- - Visualize heat diffusion on a grid. + Visualize heat diffusion on a grid by localizing the heat kernel. + >>> import matplotlib >>> N = 20 >>> G = graphs.Grid2d(N) >>> G.estimate_lmax() @@ -540,7 +541,7 @@ def localize(self, i, **kwargs): """ s = np.zeros(self.G.N) s[i] = 1 - return np.sqrt(self.G.N) * self.analysis(s, **kwargs) + return np.sqrt(self.G.N) * self.filter(s, **kwargs) def approx(self, m, N): r""" @@ -662,37 +663,31 @@ def compute_frame(self, **kwargs): Examples -------- - >>> - >>> G = graphs.Logo() + Filtering signals as a matrix multiplication. + + >>> G = graphs.Sensor(N=1000, seed=42) >>> G.estimate_lmax() + >>> f = filters.MexicanHat(G, Nf=6) + >>> s = np.random.uniform(size=G.N) >>> - >>> f = filters.MexicanHat(G) >>> frame = f.compute_frame() - >>> print('{} nodes, matrix is {} x {}'.format(G.N, *frame.shape)) - 1130 nodes, matrix is 1130 x 6780 - >>> - >>> s = np.random.uniform(size=G.N) - >>> c1 = frame.T.dot(s) - >>> c2 = f.analysis(s) + >>> frame.shape + (1000, 1000, 6) + >>> frame = frame.reshape(G.N, -1).T + >>> s1 = np.dot(frame, s) + >>> s1 = s1.reshape(G.N, 1, -1) >>> - >>> np.linalg.norm(c1 - c2) < 1e-10 + >>> s2 = f.filter(s) + >>> np.all((s1 - s2) < 1e-10) True """ - - N = self.G.N - - if N > 2000: + if self.G.N > 2000: _logger.warning('Creating a big matrix, you can use other means.') - Ft = self.analysis(np.identity(N), **kwargs) - F = np.empty(Ft.T.shape) - tmpN = np.arange(N, dtype=int) - - for i in range(self.Nf): - F[:, N * i + tmpN] = Ft[N * i + tmpN] - - return F + # Filter one delta per vertex. + s = np.identity(self.G.N) + return self.filter(s, **kwargs) def can_dual(self): r""" diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index cfc00732..9b5604ef 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -25,7 +25,6 @@ class HalfCosine(Filter): >>> G.set_coordinates('line1D') >>> g = filters.HalfCosine(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index e63089ed..1ec81822 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -55,7 +55,6 @@ class Heat(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Heat(G, tau=[5, 10, 100]) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index 9006bbee..bd224f50 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -39,7 +39,6 @@ class Held(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Held(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 6945d948..2db0b5e6 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -30,7 +30,6 @@ class Itersine(Filter): >>> G.set_coordinates('line1D') >>> g = filters.HalfCosine(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 19eb07e0..b58ca2fe 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -49,7 +49,6 @@ class MexicanHat(Filter): >>> G.set_coordinates('line1D') >>> g = filters.MexicanHat(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 9de32a62..7252e8c2 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -36,7 +36,6 @@ class Meyer(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Meyer(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index 3cc68d4d..94f29821 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -35,7 +35,6 @@ class Papadakis(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Papadakis(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index d4b15d2c..85c85987 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -43,7 +43,6 @@ class Regular(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Regular(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index ee6aa881..564197c9 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -35,7 +35,6 @@ class Simoncelli(Filter): >>> G.set_coordinates('line1D') >>> g = filters.Simoncelli(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 40c14657..5a2dcc15 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -36,7 +36,6 @@ class SimpleTight(Filter): >>> G.set_coordinates('line1D') >>> g = filters.SimpleTight(G) >>> s = g.localize(G.N // 2) - >>> s = utils.vec2mat(s, g.Nf) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) >>> G.plot_signal(s, ax=axes[1]) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 3e05ad2d..04ea177f 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -9,7 +9,7 @@ import numpy as np -from pygsp import graphs, filters +from pygsp import graphs, filters, utils class TestCase(unittest.TestCase): @@ -73,11 +73,13 @@ def _test_methods(self, f, tight): self._signal, method='lanczos') if f.Nf < 10: - F = f.compute_frame(method='chebyshev') - c_frame = F.T.dot(self._signal) + F = f.compute_frame(method='chebyshev').reshape(self._G.N, -1) + c_frame = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + c_cheby = utils.vec2mat(c_cheby, f.Nf).reshape((self._G.N, 1, -1)) np.testing.assert_allclose(c_frame, c_cheby) - F = f.compute_frame(method='exact') - c_frame = F.T.dot(self._signal) + F = f.compute_frame(method='exact').reshape(self._G.N, -1) + c_frame = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + c_exact = utils.vec2mat(c_exact, f.Nf).reshape((self._G.N, 1, -1)) np.testing.assert_allclose(c_frame, c_exact) self._test_synthesis(f) @@ -107,11 +109,11 @@ def test_localize(self): # Should be equal to a row / column of the filtering operator. gL = G.U.dot(np.diag(g.evaluate(G.e)[0]).dot(G.U.T)) s2 = np.sqrt(G.N) * gL[NODE, :] - np.testing.assert_allclose(s1, s2) + np.testing.assert_allclose(s1.squeeze(), s2) # That is actually a row / column of the analysis operator. F = g.compute_frame(method='exact') - np.testing.assert_allclose(F, gL) + np.testing.assert_allclose(F.squeeze(), gL) def test_custom_filter(self): def kernel(x): From 6e6c820fec410e3be4b9d3e79565a10b753552c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 14:27:01 +0200 Subject: [PATCH 266/392] convert tutorials to Filter.filter --- README.rst | 2 +- doc/tutorials/intro.rst | 4 ++-- doc/tutorials/wavelet.rst | 46 +++++++++++++++------------------------ 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/README.rst b/README.rst index 37ab5f9e..63737e2d 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,7 @@ This example demonstrates how to create a graph, a filter and analyse a signal o >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> f = filters.Heat(G) ->>> Sl = f.analysis(G.L.todense(), method='chebyshev') +>>> Sl = f.filter(G.L.todense(), method='chebyshev') Features -------- diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index 657f14ee..e5107158 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -134,7 +134,7 @@ As in classical signal processing, the Fourier transform plays a central role in graph signal processing. Getting the Fourier basis is however computationally intensive as it needs to fully diagonalize the Laplacian. While it can be used to filter signals on graphs, a better alternative is to use one -of the fast approximations (see :meth:`pygsp.filters.Filter.analysis`). Let's +of the fast approximations (see :meth:`pygsp.filters.Filter.filter`). Let's compute it nonetheless to visualize the eigenvectors of the Laplacian. Analogous to classical Fourier analysis, they look like sinuses on the graph. Let's plot the second and third eigenvectors (the first is constant). Those are @@ -225,7 +225,7 @@ low-pass filter. .. plot:: :context: close-figs - >>> s2 = g.analysis(s) + >>> s2 = g.filter(s) >>> >>> fig, axes = plt.subplots(1, 2, figsize=(10, 3)) >>> G.plot_signal(s, vertex_size=30, ax=axes[0]) diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index 80bbda2d..7d01981e 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -33,7 +33,7 @@ results using wavelets. :meth:`pygsp.graphs.Graph.compute_fourier_basis`, but this would take some time, and can be avoided with a Chebychev polynomials approximation to graph filtering. See the documentation of the - :meth:`pygsp.filters.Filter.analysis` filtering function and + :meth:`pygsp.filters.Filter.filter` filtering function and :cite:`hammond2011wavelets` for details on how it is down. Simple filtering: heat diffusion @@ -56,8 +56,8 @@ vertex 20. That signal is our heat source. :context: close-figs >>> s = np.zeros(G.N) - >>> delta = 20 - >>> s[delta] = 1 + >>> DELTA = 20 + >>> s[DELTA] = 1 We can now simulate heat diffusion by filtering our signal `s` with each of our heat kernels. @@ -65,8 +65,7 @@ heat kernels. .. plot:: :context: close-figs - >>> s = g.analysis(s, method='chebyshev') - >>> s = utils.vec2mat(s, g.Nf) + >>> s = g.filter(s, method='chebyshev') And finally plot the filtered signal showing heat diffusion at different scales. @@ -77,7 +76,7 @@ scales. >>> fig = plt.figure(figsize=(10, 3)) >>> for i in range(g.Nf): ... ax = fig.add_subplot(1, g.Nf, i+1, projection='3d') - ... G.plot_signal(s[:, i], vertex_size=20, colorbar=False, ax=ax) + ... G.plot_signal(s[:, 0, i], colorbar=False, ax=ax) ... title = r'Heat diffusion, $\tau={}$'.format(taus[i]) ... ax.set_title(title) #doctest:+SKIP ... ax.set_axis_off() @@ -117,29 +116,19 @@ Then plot the frequency response of those filters. bank with :class:`pygsp.filters.WarpedTranslates` or by using another filter bank like :class:`pygsp.filters.Itersine`. -We can visualize the filtering by one atom as we did with the heat kernel, by -filtering a Kronecker delta placed at one specific vertex. +We can visualize the atoms as we did with the heat kernel, by filtering +a Kronecker delta placed at one specific vertex. .. plot:: :context: close-figs - >>> s = np.zeros((G.N * g.Nf, g.Nf)) - >>> s[delta] = 1 - >>> for i in range(g.Nf): - ... s[delta + i * G.N, i] = 1 - >>> s = g.synthesis(s) + >>> s = g.localize(DELTA) >>> - >>> fig = plt.figure(figsize=(10, 7)) - >>> for i in range(4): - ... - ... # Clip the signal. - ... mu = np.mean(s[:, i]) - ... sigma = np.std(s[:, i]) - ... limits = [mu-4*sigma, mu+4*sigma] - ... - ... ax = fig.add_subplot(2, 2, i+1, projection='3d') - ... G.plot_signal(s[:, i], vertex_size=20, limits=limits, ax=ax) - ... ax.set_title('Wavelet {}'.format(i+1)) # doctest:+SKIP + >>> fig = plt.figure(figsize=(10, 2.5)) + >>> for i in range(3): + ... ax = fig.add_subplot(1, 3, i+1, projection='3d') + ... G.plot_signal(s[:, 0, i], ax=ax) + ... ax.set_title('Wavelet {}'.format(i+1)) #doctest:+SKIP ... ax.set_axis_off() >>> fig.tight_layout() # doctest:+SKIP @@ -160,16 +149,15 @@ which describes variation along the 3 coordinates. :context: close-figs >>> s = G.coords - >>> s = g.analysis(s) - >>> s = utils.vec2mat(s, g.Nf) + >>> s = g.filter(s) The curvature is then estimated by taking the :math:`\ell_1` or :math:`\ell_2` -norm of the filtered signal. +norm across the 3D position. .. plot:: :context: close-figs - >>> s = np.linalg.norm(s, ord=2, axis=2) + >>> s = np.linalg.norm(s, ord=2, axis=1) Let's finally plot the result to observe that we indeed have a measure of the curvature at different scales. @@ -180,7 +168,7 @@ curvature at different scales. >>> fig = plt.figure(figsize=(10, 7)) >>> for i in range(4): ... ax = fig.add_subplot(2, 2, i+1, projection='3d') - ... G.plot_signal(s[:, i], vertex_size=20, ax=ax) + ... G.plot_signal(s[:, i], ax=ax) ... title = 'Curvature estimation (scale {})'.format(i+1) ... ax.set_title(title) # doctest:+SKIP ... ax.set_axis_off() From 4eabf09d64ae7f0991540d2409b2fd4492344341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 14:32:04 +0200 Subject: [PATCH 267/392] convert gft_windowed_gabor to Filter.filter --- pygsp/graphs/fourier.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 5c06d12b..88fe8e1e 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -206,39 +206,32 @@ def translate(self, f, i): return ft - def gft_windowed_gabor(self, f, k): + def gft_windowed_gabor(self, s, k): r"""Gabor windowed graph Fourier transform. Parameters ---------- - f : ndarray + s : ndarray Graph signal in the vertex domain. k : function Gabor kernel. See :class:`pygsp.filters.Gabor`. Returns ------- - C : ndarray - Coefficients. + s : ndarray + Vertex-frequency representation of the signals. Examples -------- >>> G = graphs.Logo() - >>> s = np.random.normal(size=G.N) - >>> C = G.gft_windowed_gabor(s, lambda x: x/(1.-x)) - >>> C.shape == (G.N, G.N) - True + >>> s = np.random.normal(size=(G.N, 2)) + >>> s = G.gft_windowed_gabor(s, lambda x: x/(1.-x)) + >>> s.shape + (1130, 2, 1130) """ - from pygsp import filters - - g = filters.Gabor(self, k) - - C = g.analysis(f) - C = utils.vec2mat(C, self.N).T - - return C + return filters.Gabor(self, k).filter(s) def gft_windowed(self, g, f, lowmemory=True): r"""Windowed graph Fourier transform. From eac443ec52cba487be725113cedfcd4c1b66beee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 14:52:55 +0200 Subject: [PATCH 268/392] convert features.py to Filter.filter --- pygsp/features.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pygsp/features.py b/pygsp/features.py index 2cc4885a..76071316 100644 --- a/pygsp/features.py +++ b/pygsp/features.py @@ -26,39 +26,38 @@ def compute_avg_adj_deg(G): @utils.filterbank_handler -def compute_tig(filt, **kwargs): +def compute_tig(g, **kwargs): r""" - Compute the Tig for a given filter or filterbank. + Compute the Tig for a given filter or filter bank. .. math:: T_ig(n) = g(L)_{i, n} Parameters ---------- - filt: Filter object - The filter or filterbank. + g: Filter + One of :mod:`pygsp.filters`. kwargs: dict Additional parameters to be passed to the - :func:`pygsp.filters.Filter.analysis` method. + :func:`pygsp.filters.Filter.filter` method. """ - signals = np.eye(filt.G.N) - return filt.analysis(signals, **kwargs) + return g.compute_frame() @utils.filterbank_handler -def compute_norm_tig(filt, **kwargs): +def compute_norm_tig(g, **kwargs): r""" Compute the :math:`\ell_2` norm of the Tig. See :func:`compute_tig`. Parameters ---------- - filt: Filter - The filter or filterbank. + g: Filter + The filter or filter bank. kwargs: dict Additional parameters to be passed to the - :func:`pygsp.filters.Filter.analysis` method. + :func:`pygsp.filters.Filter.filter` method. """ - tig = compute_tig(filt, **kwargs) + tig = compute_tig(g, **kwargs) return np.linalg.norm(tig, axis=1, ord=2) @@ -71,13 +70,13 @@ def compute_spectrogram(G, atom=None, M=100, **kwargs): ---------- G : Graph Graph on which to compute the spectrogram. - atom : Filter kernel (optional) + atom : func Kernel to use in the spectrogram (default = exp(-M*(x/lmax)²)). M : int (optional) Number of samples on the spectral scale. (default = 100) kwargs: dict Additional parameters to be passed to the - :func:`pygsp.filters.Filter.analysis` method. + :func:`pygsp.filters.Filter.filter` method. """ if not atom: @@ -89,7 +88,8 @@ def atom(x): for shift_idx in range(M): shift_filter = filters.Filter(G, lambda x: atom(x - scale[shift_idx])) - spectr[:, shift_idx] = compute_norm_tig(shift_filter, **kwargs)**2 + tig = compute_norm_tig(shift_filter, **kwargs).squeeze()**2 + spectr[:, shift_idx] = tig G.spectr = spectr return spectr From 3ae6d117657260931ced6d5a5c9048b6cd5b8597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 15:06:01 +0200 Subject: [PATCH 269/392] convert reduction.py to Filter.filter --- pygsp/reduction.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index a8b68a03..1c1e30ee 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -24,6 +24,12 @@ logger = utils.build_logger(__name__) +def analysis(g, s, **kwargs): + # TODO: that is the legacy analysis method. + s = g.filter(s, **kwargs) + return s.swapaxes(1, 2).reshape(-1, s.shape[1], order='F') + + def graph_sparsify(M, epsilon, maxiter=10): r""" Sparsify a graph using Spielman-Srivastava algorithm. @@ -177,7 +183,7 @@ def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): f_interpolated[keep_inds] = alpha - return green_kernel.analysis(f_interpolated, order=order, **kwargs) + return analysis(green_kernel, f_interpolated, order=order, **kwargs) def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, @@ -420,7 +426,7 @@ def pyramid_analysis(Gs, f, **kwargs): for i in range(levels): # Low pass the signal - s_low = filters.Filter(Gs[i], h_filters[i]).analysis(ca[i], **kwargs) + s_low = analysis(filters.Filter(Gs[i], h_filters[i]), ca[i], **kwargs) # Keep only the coefficient on the selected nodes ca.append(s_low[Gs[i+1].mr['idx']]) # Compute prediction @@ -595,11 +601,11 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): x = np.zeros(N) z = np.concatenate((ca, pe), axis=0) green_kernel = filters.Filter(G, lambda x: 1./(x+reg_eps)) - PhiVlt = green_kernel.analysis(S.T, **kwargs).T + PhiVlt = analysis(green_kernel, S.T, **kwargs).T filt = filters.Filter(G, h_filter, **kwargs) for iteration in range(landweber_its): - h_filtered_sig = filt.analysis(x, **kwargs) + h_filtered_sig = analysis(filt, x, **kwargs) x_bar = h_filtered_sig[keep_inds] y_bar = x - interpolate(G, x_bar, keep_inds, **kwargs) z_delt = np.concatenate((x_bar, y_bar), axis=0) @@ -616,7 +622,7 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): next_term = L_red * alpha_new - L_in_out * linalg.spsolve(L_comp, L_out_in * alpha_new) next_up = sparse.csr_matrix((next_term, (keep_inds, [1] * nb_ind)), shape=(N, 1)) - x += landweber_tau * filt.analysis(x_up - next_up, **kwargs) + z_delt[nb_ind:] + x += landweber_tau * analysis(filt, x_up - next_up, **kwargs) + z_delt[nb_ind:] finer_approx = x From fdfb3ca8b469be51935c2869f7865e2b6935397e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 15:19:01 +0200 Subject: [PATCH 270/392] remove useless pyramid_cell2coeff --- pygsp/reduction.py | 48 ---------------------------------------------- 1 file changed, 48 deletions(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 1c1e30ee..42dae288 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -9,7 +9,6 @@ * :func:`kron_reduction`: compute the Kron reduction * :func:`pyramid_analysis`: analysis operator for graph pyramid * :func:`pyramid_synthesis`: synthesis operator for graph pyramid -* :func:`pyramid_cell2coeff`: keep only the necessary coefficients * :func:`interpolate`: interpolate a signal * :func:`graph_sparsify`: sparsify a graph """ @@ -90,7 +89,6 @@ def graph_sparsify(M, epsilon, maxiter=10): W = W.tocsc() W.eliminate_zeros() - start_nodes, end_nodes, weights = sparse.find(sparse.tril(W)) # Calculate the new weights. @@ -437,52 +435,6 @@ def pyramid_analysis(Gs, f, **kwargs): return ca, pe -def pyramid_cell2coeff(ca, pe): - r""" - Cell array to vector transform for the pyramid. - NOT NECESSARY ANYMORE. - - Parameters - ---------- - ca : ndarray - Array with the coarse approximation at each level - pe : ndarray - Array with the prediction errors at each level - - Returns - ------- - coeff : ndarray - Array of coefficient - - """ - Nl = len(ca) - 1 - N = 0 - - for ele in ca: - N += np.shape(ele)[0] - - try: - Nt, Nv = np.shape(ca[Nl]) - coeff = np.zeros((N, Nv)) - except ValueError: - Nt = np.shape(ca[Nl])[0] - coeff = np.zeros((N)) - - coeff[:Nt] = ca[Nl] - ind = Nt - - for i in range(Nl): - Nt = np.shape(ca[Nl - 1 - i])[0] - tmpNt = np.arange(Nt, dtype=int) - coeff[ind + tmpNt] = pe[Nl - 1 - i] - ind += Nt - - if ind != N: - raise ValueError('Something is wrong here: contact the gspbox team.') - - return coeff - - def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): r""" Synthesize a signal from its graph pyramid transform coefficients. From d1cc37c29682c983f580f8d910ce33e37e94d4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 15:54:47 +0200 Subject: [PATCH 271/392] reduction: use autosummary --- pygsp/reduction.py | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 42dae288..7307d414 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -4,13 +4,16 @@ The :mod:`pygsp.reduction` module implements functionalities for the reduction of graphs' vertex set while keeping the graph structure. -* :func:`tree_multiresolution`: compute a multiresolution of trees -* :func:`graph_multiresolution`: compute a pyramid of graphs -* :func:`kron_reduction`: compute the Kron reduction -* :func:`pyramid_analysis`: analysis operator for graph pyramid -* :func:`pyramid_synthesis`: synthesis operator for graph pyramid -* :func:`interpolate`: interpolate a signal -* :func:`graph_sparsify`: sparsify a graph +.. autosummary:: + + tree_multiresolution + graph_multiresolution + kron_reduction + pyramid_analysis + pyramid_synthesis + interpolate + graph_sparsify + """ import numpy as np @@ -30,8 +33,7 @@ def analysis(g, s, **kwargs): def graph_sparsify(M, epsilon, maxiter=10): - r""" - Sparsify a graph using Spielman-Srivastava algorithm. + r"""Sparsify a graph (with Spielman-Srivastava). Parameters ---------- @@ -139,8 +141,7 @@ def graph_sparsify(M, epsilon, maxiter=10): def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): - r""" - Interpolation of a graph signal. + r"""Interpolate a graph signal. Parameters ---------- @@ -188,8 +189,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, downsampling_method='largest_eigenvector', reduction_method='kron', compute_full_eigen=False, reg_eps=0.005): - r""" - Compute a pyramid of graphs using the kron reduction. + r"""Compute a pyramid of graphs (by Kron reduction). 'graph_multiresolution(G,levels)' computes a multiresolution of graph by repeatedly downsampling and performing graph reduction. The @@ -292,8 +292,7 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, def kron_reduction(G, ind): - r""" - Compute the kron reduction. + r"""Compute the Kron reduction. This function perform the Kron reduction of the weight matrix in the graph *G*, with boundary nodes labeled by *ind*. This function will @@ -369,8 +368,7 @@ def kron_reduction(G, ind): def pyramid_analysis(Gs, f, **kwargs): - r""" - Compute the graph pyramid transform coefficients. + r"""Compute the graph pyramid transform coefficients. Parameters ---------- @@ -436,8 +434,7 @@ def pyramid_analysis(Gs, f, **kwargs): def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): - r""" - Synthesize a signal from its graph pyramid transform coefficients. + r"""Synthesize a signal from its pyramid coefficients. Parameters ---------- @@ -507,8 +504,7 @@ def pyramid_synthesis(Gs, cap, pe, order=30, **kwargs): def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): - r""" - Synthesize a single level of the graph pyramid transform. + r"""Synthesize a single level of the graph pyramid transform. Parameters ---------- @@ -588,7 +584,6 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): def _tree_depths(A, root): - r"""Empty docstring. TODO.""" if not graphs.Graph(A=A).is_connected(): raise ValueError('Graph is not connected') @@ -619,8 +614,7 @@ def _tree_depths(A, root): def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', compute_full_eigen=False, root=None): - r""" - Compute a multiresolution of trees + r"""Compute a multiresolution of trees Parameters ---------- From 9a9eafbfc50c0eb284df7b39a026ddf349d3da43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 16:08:12 +0200 Subject: [PATCH 272/392] remove Filter.analysis and Filter.synthesis --- pygsp/filters/__init__.py | 13 +- pygsp/filters/filter.py | 233 +----------------------------------- pygsp/graphs/fourier.py | 16 +-- pygsp/tests/test_filters.py | 73 +++++------ pygsp/tests/test_graphs.py | 11 +- pygsp/utils.py | 31 ----- 6 files changed, 49 insertions(+), 328 deletions(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index dfd0dc7e..400a950b 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- r""" -The :mod:`pygsp.filters` module implements methods used for filtering (e.g. -analysis, synthesis, evaluation) and defines commonly used filters that can be -applied to :mod:`pygsp.graphs`. A filter is associated to a graph and is -defined with one or several functions. We define by filter bank a list of -filters, usually centered around different frequencies, applied to a single -graph. +The :mod:`pygsp.filters` module implements methods used for filtering and +defines commonly used filters that can be applied to :mod:`pygsp.graphs`. A +filter is associated to a graph and is defined with one or several functions. +We define by filter bank a list of filters, usually centered around different +frequencies, applied to a single graph. Interface --------- @@ -17,8 +16,6 @@ Filter.evaluate Filter.filter - Filter.analysis - Filter.synthesis Filter.compute_frame Filter.estimate_frame_bounds Filter.plot diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index e23ed85f..f9a194c2 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -50,7 +50,7 @@ class Filter(object): >>> signal = np.zeros(G.N) >>> signal[42] = 1 >>> - >>> filtered_signal = my_filter.analysis(signal) + >>> filtered_signal = my_filter.filter(signal) """ @@ -66,110 +66,6 @@ def __init__(self, G, kernels): self.Nf = len(self.g) - def analysis(self, s, method='chebyshev', order=30): - r"""Compute signal response to the filter bank. - - This operation is also referred to as filtering a signal or as the - analysis operator. - - The method computes the transform coefficients of a signal :math:`s`, - where the atoms of the transform dictionary are generalized - translations of each graph spectral filter to each vertex on the graph: - - .. math:: c = D^* s, - - where the columns of :math:`D` are :math:`g_{i,m} = T_i g_m` and - :math:`T_i` is a generalized translation operator applied to each - filter :math:`\hat{g}_m(\cdot)`. Each column of :math:`c` is the - response of the signal to one filter. - - In other words, this function is applying the analysis operator - :math:`D^*` associated with the frame defined by the filter bank to the - signals. - - Parameters - ---------- - s : ndarray - Graph signals to analyze, a matrix of size N x Ns where N is the - number of nodes and Ns the number of signals. - method : 'exact', 'chebyshev', 'lanczos' - Whether to use the exact method (via the graph Fourier transform) - or the Chebyshev polynomial approximation. The Lanczos - approximation is not working yet. - order : int - Degree of the Chebyshev polynomials. - - Returns - ------- - c : ndarray - Transform coefficients, a matrix of size Nf*N x Ns where Nf is the - number of filters, N the number of nodes, and Ns the number of - signals. - - See Also - -------- - synthesis : adjoint of the analysis operator - - References - ---------- - See :cite:`hammond2011wavelets` for more details. - - Examples - -------- - Create a smooth graph signal by low-pass filtering white noise. - - >>> G = graphs.Logo() - >>> G.estimate_lmax() - >>> s1 = np.random.uniform(size=(G.N, 4)) - >>> s2 = filters.Expwin(G).analysis(s1) - >>> G.plot_signal(s1[:, 0]) - >>> G.plot_signal(s2[:, 0]) - - """ - - if method == 'chebyshev': - cheb_coef = approximations.compute_cheby_coeff(self, m=order) - c = approximations.cheby_op(self.G, cheb_coef, s) - - elif method == 'lanczos': - raise NotImplementedError - # c = approximations.lanczos_op(self, s, order=order) - - elif method == 'exact': - N = self.G.N # nb of nodes - try: - Ns = np.shape(s)[1] # nb signals - c = np.zeros((N * self.Nf, Ns)) - is2d = True - except IndexError: - c = np.zeros((N * self.Nf)) - is2d = False - - fie = self.evaluate(self.G.e).squeeze() - - if self.Nf == 1: - if is2d: - fs = np.tile(fie, (Ns, 1)).T * self.G.gft(s) - return self.G.igft(fs) - else: - fs = fie * self.G.gft(s) - return self.G.igft(fs) - else: - tmpN = np.arange(N, dtype=int) - for i in range(self.Nf): - if is2d: - fs = self.G.gft(s) - fs *= np.tile(fie[i], (Ns, 1)).T - c[tmpN + N * i] = self.G.igft(fs) - else: - fs = fie[i] * self.G.gft(s) - c[tmpN + N * i] = self.G.igft(fs) - - else: - raise ValueError('Unknown method: {}'.format(method)) - - return c - def evaluate(self, x): r"""Evaluate the kernels at given frequencies. @@ -343,9 +239,9 @@ def filter(self, s, method='chebyshev', order=30): f = np.expand_dims(f.T, axis) assert f.shape == (N_NODES, N_FEATURES_IN, N_FEATURES_OUT) - s = self.G.gft2(s) + s = self.G.gft(s) s = np.matmul(s, f) - s = self.G.igft2(s) + s = self.G.igft(s) elif method == 'chebyshev': @@ -380,129 +276,6 @@ def inverse(self, c): """ raise NotImplementedError - def synthesis(self, c, method='chebyshev', order=30): - r"""Synthesize signal from filter bank response. - - This operation is also referred to as the synthesis operator. - - The method synthesizes a signal :math:`s` from its coefficients - :math:`c`, where the atoms of the transform dictionary are generalized - translations of each graph spectral filter to each vertex on the graph: - - .. math:: s = D c, - - where the columns of :math:`D` are :math:`g_{i,m} = T_i g_m` and - :math:`T_i` is a generalized translation operator applied to each - filter :math:`\hat{g}_m(\cdot)`. - - In other words, this function is applying the synthesis operator - :math:`D` associated with the frame defined from the filter bank to the - coefficients. - - Parameters - ---------- - c : ndarray - Transform coefficients, a matrix of size Nf*N x Ns where Nf is the - number of filters, N the number of nodes, and Ns the number of - signals. - method : 'exact', 'chebyshev', 'lanczos' - Whether to use the exact method (via the graph Fourier transform) - or the Chebyshev polynomial approximation. The Lanczos - approximation is not working yet. - order : int - Degree of the Chebyshev approximation. - - Returns - ------- - s : ndarray - Synthesized graph signals, a matrix of size N x Ns where N is the - number of nodes and Ns the number of signals. - - See Also - -------- - analysis : adjoint of the synthesis operator - - References - ---------- - See :cite:`hammond2011wavelets` for more details. - - Examples - -------- - >>> G = graphs.Sensor(30, seed=42) - >>> G.estimate_lmax() - - Localized smooth signal: - - >>> s1 = np.zeros((G.N, 1)) - >>> s1[13] = 1 - >>> s1 = filters.Heat(G, tau=3).analysis(s1) - - Filter and reconstruct our signal: - - >>> g = filters.MexicanHat(G, Nf=4) - >>> c = g.analysis(s1) - >>> s2 = g.synthesis(c) - - Look how well we were able to reconstruct: - - >>> g.plot() - >>> G.plot_signal(s1[:, 0]) - >>> G.plot_signal(s2[:, 0]) - >>> print('{:.1f}'.format(np.linalg.norm(s1 - s2))) - 0.3 - - Perfect reconstruction with Itersine, a tight frame: - - >>> g = filters.Itersine(G) - >>> c = g.analysis(s1) - >>> s2 = g.synthesis(c) - >>> err = np.linalg.norm(s1 - s2) - >>> print('{:.2f}'.format(np.linalg.norm(s1 - s2))) - 0.00 - - """ - - N = self.G.N - - if method == 'exact': - fie = self.evaluate(self.G.e) - Nv = np.shape(c)[1] - s = np.zeros((N, Nv)) - tmpN = np.arange(N, dtype=int) - - if self.Nf == 1: - fc = np.tile(fie, (Nv, 1)).T * self.G.gft(c[tmpN]) - s += self.G.igft(fc) - else: - for i in range(self.Nf): - fc = self.G.gft(c[N * i + tmpN]) - fc *= np.tile(fie[:][i], (Nv, 1)).T - s += self.G.igft(fc) - - elif method == 'chebyshev': - cheb_coeffs = approximations.compute_cheby_coeff(self, m=order, - N=order+1) - s = np.zeros((N, np.shape(c)[1])) - tmpN = np.arange(N, dtype=int) - - for i in range(self.Nf): - s += approximations.cheby_op(self.G, - cheb_coeffs[i], c[i * N + tmpN]) - - elif method == 'lanczos': - raise NotImplementedError - s = np.zeros((N, np.shape(c)[1])) - tmpN = np.arange(N, dtype=int) - - for i in range(self.Nf): - s += approximations.lanczos_op(self.G, self.g[i], - c[i * N + tmpN], order=order) - - else: - raise ValueError('Unknown method: {}'.format(method)) - - return s - def localize(self, i, **kwargs): r"""Localize the kernels at a node (to visualize them). diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index 88fe8e1e..b2a3e49f 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -130,16 +130,14 @@ def gft(self, s): Examples -------- >>> G = graphs.Logo() - >>> s = np.random.normal(size=G.N) + >>> G.compute_fourier_basis() + >>> s = np.random.normal(size=(G.N, 5, 1)) >>> s_hat = G.gft(s) >>> s_star = G.igft(s_hat) - >>> np.linalg.norm(s - s_star) < 1e-10 + >>> np.all((s - s_star) < 1e-10) True """ - return np.dot(np.conjugate(self.U.T), s) # True Hermitian here. - - def gft2(self, s): s = self.sanitize_signal(s) U = np.conjugate(self.U) # True Hermitian. (Although U is often real.) return np.tensordot(U, s, ([0], [0])) @@ -167,16 +165,14 @@ def igft(self, s_hat): Examples -------- >>> G = graphs.Logo() - >>> s_hat = np.random.normal(size=G.N) + >>> G.compute_fourier_basis() + >>> s_hat = np.random.normal(size=(G.N, 5, 1)) >>> s = G.igft(s_hat) >>> s_hat_star = G.gft(s) - >>> np.linalg.norm(s_hat - s_hat_star) < 1e-10 + >>> np.all((s_hat - s_hat_star) < 1e-10) True """ - return np.dot(self.U, s_hat) - - def igft2(self, s_hat): s_hat = self.sanitize_signal(s_hat) return np.tensordot(self.U, s_hat, ([1], [0])) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 04ea177f..1328705d 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -9,7 +9,7 @@ import numpy as np -from pygsp import graphs, filters, utils +from pygsp import graphs, filters class TestCase(unittest.TestCase): @@ -32,15 +32,17 @@ def _generate_coefficients(self, N, Nf, vertex_delta=83): S[vertex_delta + i * self._G.N, i] = 1 return S - def _test_synthesis(self, f): - if 1 < f.Nf < 10: - S = self._generate_coefficients(f.G.N, f.Nf) - f.synthesis(S, method='chebyshev') - f.synthesis(S, method='exact') - self.assertRaises(NotImplementedError, f.synthesis, S, - method='lanczos') + def _test_methods(self, f, tight): + self.assertIs(f.G, self._G) + + f.evaluate(self._G.e) + + A, B = f.estimate_frame_bounds(use_eigenvalues=True) + if tight: + np.testing.assert_allclose(A, B) + else: + assert B - A > 0.01 - def _test_filter(self, f, tight): # Analysis. s2 = f.filter(self._signal, method='exact') s3 = f.filter(self._signal, method='chebyshev', order=100) @@ -50,46 +52,30 @@ def _test_filter(self, f, tight): s5 = f.filter(s3, method='chebyshev', order=100) if f.Nf < 100: + # Chebyshev should be close to exact. # TODO: does not pass for Gabor. np.testing.assert_allclose(s2, s3, rtol=0.1, atol=0.01) np.testing.assert_allclose(s4, s5, rtol=0.1, atol=0.01) if tight: - A, _ = f.estimate_frame_bounds(use_eigenvalues=True) + # Tight frames should not loose information. np.testing.assert_allclose(s4.squeeze(), A * self._signal) assert np.linalg.norm(s5.squeeze() - A * self._signal) < 0.1 - def _test_methods(self, f, tight): - self.assertIs(f.G, self._G) - - self._test_filter(f, tight) - - c_exact = f.analysis(self._signal, method='exact') - c_cheby = f.analysis(self._signal, method='chebyshev') - self.assertEqual(c_exact.shape, c_cheby.shape) - # TODO: a bit far for some filterbanks. - # np.testing.assert_allclose(c_exact, c_cheby) - self.assertRaises(NotImplementedError, f.analysis, - self._signal, method='lanczos') + self.assertRaises(ValueError, f.filter, s2, method='lanczos') if f.Nf < 10: - F = f.compute_frame(method='chebyshev').reshape(self._G.N, -1) - c_frame = F.T.dot(self._signal).reshape(self._G.N, 1, -1) - c_cheby = utils.vec2mat(c_cheby, f.Nf).reshape((self._G.N, 1, -1)) - np.testing.assert_allclose(c_frame, c_cheby) - F = f.compute_frame(method='exact').reshape(self._G.N, -1) - c_frame = F.T.dot(self._signal).reshape(self._G.N, 1, -1) - c_exact = utils.vec2mat(c_exact, f.Nf).reshape((self._G.N, 1, -1)) - np.testing.assert_allclose(c_frame, c_exact) - - self._test_synthesis(f) - f.evaluate(self._G.e) - - A, B = f.estimate_frame_bounds(use_eigenvalues=True) - if tight: - np.testing.assert_allclose(A, B) - else: - assert B - A > 0.01 + # Computing the frame is an alternative way to filter. + # Though it is memory intensive. + F = f.compute_frame(method='exact') + F = F.reshape(self._G.N, -1) + s = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + np.testing.assert_allclose(s, s2) + + F = f.compute_frame(method='chebyshev', order=100) + F = F.reshape(self._G.N, -1) + s = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + np.testing.assert_allclose(s, s3) # TODO: f.can_dual() @@ -201,15 +187,14 @@ def test_approximations(self): Test that the different methods for filter analysis, i.e. 'exact', 'cheby', and 'lanczos', produce the same output. """ - # TODO: synthesis + # TODO: done in _test_methods. f = filters.Heat(self._G) - c_exact = f.analysis(self._signal, method='exact') - c_cheby = f.analysis(self._signal, method='chebyshev') + c_exact = f.filter(self._signal, method='exact') + c_cheby = f.filter(self._signal, method='chebyshev') np.testing.assert_allclose(c_exact, c_cheby) - self.assertRaises(NotImplementedError, f.analysis, - self._signal, method='lanczos') + self.assertRaises(ValueError, f.filter, self._signal, method='lanczos') suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 0855f512..6c3d1136 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -20,8 +20,8 @@ def setUpClass(cls): cls._G = graphs.Logo() cls._G.compute_fourier_basis() - rs = np.random.RandomState(42) - cls._signal = rs.uniform(size=cls._G.N) + cls._rs = np.random.RandomState(42) + cls._signal = cls._rs.uniform(size=cls._G.N) cls._img = img_as_float(data.camera()[::16, ::16]) @@ -57,9 +57,10 @@ def test_laplacian(self): lap_type='normalized') def test_fourier_transform(self): - f_hat = self._G.gft(self._signal) - f_star = self._G.igft(f_hat) - np.testing.assert_allclose(self._signal, f_star) + s = self._rs.uniform(size=(self._G.N, 99, 21)) + s_hat = self._G.gft(s) + s_star = self._G.igft(s_hat) + np.testing.assert_allclose(s, s_star) def test_gft_windowed_gabor(self): self._G.gft_windowed_gabor(self._signal, lambda x: x/(1.-x)) diff --git a/pygsp/utils.py b/pygsp/utils.py index 18cf09f7..80ce0dfa 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -324,11 +324,6 @@ def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): return np.exp(np.linspace(np.log(scale_max), np.log(scale_min), Nscales)) -def mat2vec(d): - r"""Not implemented yet.""" - raise NotImplementedError - - def repmatline(A, ncol=1, nrow=1): r""" Repeat the matrix A in a specific manner. @@ -367,32 +362,6 @@ def repmatline(A, ncol=1, nrow=1): return np.repeat(np.repeat(A, ncol, axis=1), nrow, axis=0) -def vec2mat(d, Nf): - r""" - Vector to matrix transformation. - - Parameters - ---------- - d : ndarray - Coefficients from :func:`pygsp.filters.Filter.analysis`. - Nf : int - Number of filters. - - Returns - ------- - d : list of ndarray - Reshaped coefficients. - - """ - if d.ndim == 1: - M = d.shape[0] - return d.reshape((M // Nf, Nf), order='F') - - elif d.ndim == 2: - M, N = d.shape - return d.reshape((M // Nf, Nf, N), order='F') - - def extract_patches(img, patch_shape=(3, 3)): r""" Extract a patch feature vector for every pixel of an image. From 929925da23a1633feee9395f15cadd2af4f47195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 16:10:01 +0200 Subject: [PATCH 273/392] filters: remove not implemented methods --- pygsp/filters/filter.py | 18 ------------------ pygsp/tests/test_filters.py | 4 ---- 2 files changed, 22 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index f9a194c2..3f5f5477 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -270,12 +270,6 @@ def filter(self, s, method='chebyshev', order=30): return s - def inverse(self, c): - r""" - Not implemented yet. - """ - raise NotImplementedError - def localize(self, i, **kwargs): r"""Localize the kernels at a node (to visualize them). @@ -316,18 +310,6 @@ def localize(self, i, **kwargs): s[i] = 1 return np.sqrt(self.G.N) * self.filter(s, **kwargs) - def approx(self, m, N): - r""" - Not implemented yet. - """ - raise NotImplementedError - - def tighten(self): - r""" - Not implemented yet. - """ - raise NotImplementedError - def estimate_frame_bounds(self, min=0, max=None, N=1000, use_eigenvalues=False): r"""Estimate lower and upper frame bounds. diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 1328705d..90f243e5 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -79,10 +79,6 @@ def _test_methods(self, f, tight): # TODO: f.can_dual() - self.assertRaises(NotImplementedError, f.approx, 0, 0) - self.assertRaises(NotImplementedError, f.inverse, 0) - self.assertRaises(NotImplementedError, f.tighten) - def test_localize(self): G = graphs.Grid2d(20) G.compute_fourier_basis() From db81c237129d5f6f300d9c9e8a6e0be693d61119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 16:26:12 +0200 Subject: [PATCH 274/392] filters: kernels as private attributes --- pygsp/filters/approximations.py | 4 ++-- pygsp/filters/filter.py | 18 +++++++----------- pygsp/tests/test_filters.py | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/pygsp/filters/approximations.py b/pygsp/filters/approximations.py index 1b234ab2..905ed3e8 100644 --- a/pygsp/filters/approximations.py +++ b/pygsp/filters/approximations.py @@ -49,8 +49,8 @@ def compute_cheby_coeff(f, m=30, N=None, *args, **kwargs): tmpN = np.arange(N) num = np.cos(np.pi * (tmpN + 0.5) / N) for o in range(m + 1): - c[o] = 2. / N * np.dot(f.g[i](a1 * num + a2), - np.cos(np.pi * o * (tmpN + 0.5) / N)) + c[o] = 2. / N * np.dot(f._kernels[i](a1 * num + a2), + np.cos(np.pi * o * (tmpN + 0.5) / N)) return c diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 3f5f5477..66672acc 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- -import copy - import numpy as np from pygsp import utils @@ -62,9 +60,9 @@ def __init__(self, G, kernels): iter(kernels) except TypeError: kernels = [kernels] - self.g = kernels + self._kernels = kernels - self.Nf = len(self.g) + self.Nf = len(kernels) def evaluate(self, x): r"""Evaluate the kernels at given frequencies. @@ -93,9 +91,9 @@ def evaluate(self, x): [] """ - # Avoid to copy data as with np.array([g(x) for g in self.g]). + # Avoid to copy data as with np.array([g(x) for g in self._kernels]). y = np.empty((self.Nf, len(x))) - for i, g in enumerate(self.g): + for i, g in enumerate(self._kernels): y[i] = g(x) return y @@ -462,13 +460,11 @@ def can_dual_func(g, n, x): ret = s[:, n] return ret - gdual = copy.deepcopy(self) - + kernels = [] for i in range(self.Nf): - gdual.g[i] = lambda x, ind=i: can_dual_func(self, ind, - copy.deepcopy(x)) + kernels.append(lambda x, i=i: can_dual_func(self, i, x)) - return gdual + return Filter(self.G, kernels) def plot(self, **kwargs): r"""Plot the filter bank's frequency response. diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 90f243e5..180132ef 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -102,7 +102,7 @@ def kernel(x): return x / (1. + x) f = filters.Filter(self._G, kernels=kernel) self.assertEqual(f.Nf, 1) - self.assertIs(f.g[0], kernel) + self.assertIs(f._kernels[0], kernel) self._test_methods(f, tight=False) def test_abspline(self): From 584b0a95c86b3bbf3c5107e627299925a80b3f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 16:33:44 +0200 Subject: [PATCH 275/392] wording --- pygsp/filters/filter.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 66672acc..ffb07528 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -98,8 +98,7 @@ def evaluate(self, x): return y def filter(self, s, method='chebyshev', order=30): - r""" - Filter signals with the filter bank (analysis or synthesis). + r"""Filter signals (analysis or synthesis). A signal is defined as a rank-3 tensor of shape ``(N_NODES, N_SIGNALS, N_FEATURES)``, where ``N_NODES`` is the number of nodes in the graph, @@ -443,9 +442,8 @@ def compute_frame(self, **kwargs): return self.filter(s, **kwargs) def can_dual(self): - r""" - Creates a dual graph form a given graph - """ + r"""Creates a dual graph form a given graph""" + def can_dual_func(g, n, x): # Nshape = np.shape(x) x = np.ravel(x) From 02261d594698b9c1ffe59ab481415c921e32f623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 1 Sep 2017 16:38:33 +0200 Subject: [PATCH 276/392] graphs: don't check connectedness by default It is only interesting for user created graph, they can check it themselves if relevant. --- pygsp/graphs/graph.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 077a59b9..b67cf97f 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -73,7 +73,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): """ def __init__(self, W, gtype='unknown', lap_type='combinatorial', - coords=None, plotting={}, perform_checks=True, **kwargs): + coords=None, plotting={}, **kwargs): self.logger = utils.build_logger(__name__, **kwargs) @@ -95,11 +95,6 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', if coords is not None: self.coords = coords - # Very expensive for big graphs. Allow user to opt out. - if perform_checks: - if not self.is_connected(): - self.logger.warning('Graph is not connected!') - self.plotting = {'vertex_size': 100, 'vertex_color': (0.12, 0.47, 0.71, 1), 'edge_color': (0.5, 0.5, 0.5, 1), From 445120dcb5eb6698dd2003107575a0861e3a1639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 10:40:58 +0200 Subject: [PATCH 277/392] reduction: analyis as private function --- pygsp/reduction.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 7307d414..b28c046a 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -26,7 +26,7 @@ logger = utils.build_logger(__name__) -def analysis(g, s, **kwargs): +def _analysis(g, s, **kwargs): # TODO: that is the legacy analysis method. s = g.filter(s, **kwargs) return s.swapaxes(1, 2).reshape(-1, s.shape[1], order='F') @@ -182,7 +182,7 @@ def interpolate(G, f_subsampled, keep_inds, order=100, reg_eps=0.005, **kwargs): f_interpolated[keep_inds] = alpha - return analysis(green_kernel, f_interpolated, order=order, **kwargs) + return _analysis(green_kernel, f_interpolated, order=order, **kwargs) def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, @@ -422,7 +422,7 @@ def pyramid_analysis(Gs, f, **kwargs): for i in range(levels): # Low pass the signal - s_low = analysis(filters.Filter(Gs[i], h_filters[i]), ca[i], **kwargs) + s_low = _analysis(filters.Filter(Gs[i], h_filters[i]), ca[i], **kwargs) # Keep only the coefficient on the selected nodes ca.append(s_low[Gs[i+1].mr['idx']]) # Compute prediction @@ -549,11 +549,11 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): x = np.zeros(N) z = np.concatenate((ca, pe), axis=0) green_kernel = filters.Filter(G, lambda x: 1./(x+reg_eps)) - PhiVlt = analysis(green_kernel, S.T, **kwargs).T + PhiVlt = _analysis(green_kernel, S.T, **kwargs).T filt = filters.Filter(G, h_filter, **kwargs) for iteration in range(landweber_its): - h_filtered_sig = analysis(filt, x, **kwargs) + h_filtered_sig = _analysis(filt, x, **kwargs) x_bar = h_filtered_sig[keep_inds] y_bar = x - interpolate(G, x_bar, keep_inds, **kwargs) z_delt = np.concatenate((x_bar, y_bar), axis=0) @@ -570,7 +570,7 @@ def _pyramid_single_interpolation(G, ca, pe, keep_inds, h_filter, **kwargs): next_term = L_red * alpha_new - L_in_out * linalg.spsolve(L_comp, L_out_in * alpha_new) next_up = sparse.csr_matrix((next_term, (keep_inds, [1] * nb_ind)), shape=(N, 1)) - x += landweber_tau * analysis(filt, x_up - next_up, **kwargs) + z_delt[nb_ind:] + x += landweber_tau * _analysis(filt, x_up - next_up, **kwargs) + z_delt[nb_ind:] finer_approx = x From cab455cd13b72a761eee1ab5c0a22a20ced6b15a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 17:21:19 +0200 Subject: [PATCH 278/392] tests: fourier basis and various eigendecompositions --- pygsp/tests/test_filters.py | 2 +- pygsp/tests/test_graphs.py | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 180132ef..903fe20f 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -84,7 +84,7 @@ def test_localize(self): G.compute_fourier_basis() g = filters.Heat(G, 100) - # Localize signal at node by filterting Kronecker delta. + # Localize signal at node by filtering Kronecker delta. NODE = 10 s1 = g.localize(NODE, method='exact') diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 6c3d1136..c2321db5 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -8,6 +8,7 @@ import unittest import numpy as np +import scipy.linalg from skimage import data, img_as_float from pygsp import graphs @@ -56,6 +57,55 @@ def test_laplacian(self): self.assertRaises(NotImplementedError, G.compute_laplacian, lap_type='normalized') + def test_fourier_basis(self): + # Smallest eigenvalue close to zero. + np.testing.assert_allclose(self._G.e[0], 0, atol=1e-12) + # First eigenvector is constant. + N = self._G.N + np.testing.assert_allclose(self._G.U[:, 0], np.sqrt(N) / N) + # Control eigenvector direction. + # assert (self._G.U[0, :] > 0).all() + # Spectrum bounded by [0, 2] for the normalized Laplacian. + G = graphs.Logo(lap_type='normalized') + G.compute_fourier_basis() + assert G.e[-1] < 2 + + def test_eigendecompositions(self): + G = graphs.Logo() + U1, e1, V1 = scipy.linalg.svd(G.L.toarray()) + U2, e2, V2 = np.linalg.svd(G.L.toarray()) + e3, U3 = np.linalg.eig(G.L.toarray()) + e4, U4 = scipy.linalg.eig(G.L.toarray()) + e5, U5 = np.linalg.eigh(G.L.toarray()) + e6, U6 = scipy.linalg.eigh(G.L.toarray()) + + def correct_sign(U): + signs = np.sign(U[0, :]) + signs[signs == 0] = 1 + return U * signs + U1 = correct_sign(U1) + U2 = correct_sign(U2) + U3 = correct_sign(U3) + U4 = correct_sign(U4) + U5 = correct_sign(U5) + U6 = correct_sign(U6) + V1 = correct_sign(V1.T) + V2 = correct_sign(V2.T) + + inds = np.argsort(e3)[::-1] + np.testing.assert_allclose(e2, e1) + np.testing.assert_allclose(e3[inds], e1, atol=1e-12) + np.testing.assert_allclose(e4[inds], e1, atol=1e-12) + np.testing.assert_allclose(e5[::-1], e1, atol=1e-12) + np.testing.assert_allclose(e6[::-1], e1, atol=1e-12) + np.testing.assert_allclose(U2, U1) + np.testing.assert_allclose(V1, U1, atol=1e-12) + np.testing.assert_allclose(V2, U1, atol=1e-12) + np.testing.assert_allclose(U3[:, inds], U1, atol=1e-10) + np.testing.assert_allclose(U4[:, inds], U1, atol=1e-10) + np.testing.assert_allclose(U5[:, ::-1], U1, atol=1e-10) + np.testing.assert_allclose(U6[:, ::-1], U1, atol=1e-10) + def test_fourier_transform(self): s = self._rs.uniform(size=(self._G.N, 99, 21)) s_hat = self._G.gft(s) From d2c1b55c01f390a7a0e93c373b27d8f309198e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 18:01:44 +0200 Subject: [PATCH 279/392] fourier basis: use eigh instead of svd (faster) --- pygsp/graphs/fourier.py | 28 +++++++++++++++++++--------- pygsp/graphs/graph.py | 6 ++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index b2a3e49f..d646eb45 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import numpy as np -import scipy.linalg from pygsp import utils @@ -93,18 +92,29 @@ def compute_fourier_basis(self, recompute=False): if hasattr(self, '_e') and hasattr(self, '_U') and not recompute: return + assert self.L.shape == (self.N, self.N) if self.N > 3000: - self.logger.warning("Performing full eigendecomposition of a " - "large matrix may take some time.") + self.logger.warning('Computing the full eigendecomposition of a ' + 'large matrix ({0} x {0}) may take some ' + 'time.'.format(self.N)) - # TODO: np.linalg.{svd,eigh}, sparse.linalg.{svds,eigsh} - eigenvectors, eigenvalues, _ = scipy.linalg.svd(self.L.todense()) + # TODO: handle non-symmetric Laplatians. Test lap_type? - inds = np.argsort(eigenvalues) + self._e, self._U = np.linalg.eigh(self.L.toarray()) + # Columns are eigenvectors. Sorted in ascending eigenvalue order. - self._e = np.sort(eigenvalues) - self._lmax = np.max(self._e) - self._U = eigenvectors[:, inds] + # Smallest eigenvalue should be zero: correct numerical errors. + # Eigensolver might sometimes return small negative values, which + # filter's implementations may not anticipate. Better for plotting too. + assert -1e-12 < self._e[0] < 1e-12 + self._e[0] = 0 + + if self.lap_type == 'normalized': + # Spectrum bounded by [0, 2]. + assert self._e[-1] <= 2 + + assert np.max(self._e) == self._e[-1] + self._lmax = self._e[-1] self._mu = np.max(np.abs(self._U)) def gft(self, s): diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index b67cf97f..1e61535b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -569,14 +569,12 @@ def compute_laplacian(self, lap_type='combinatorial'): >>> >>> G.compute_laplacian('combinatorial') >>> G.compute_fourier_basis() - >>> 0 < G.e[0] < 1e-10 # Smallest eigenvalue close to 0. + >>> -1e-10 < G.e[0] < 1e-10 # Smallest eigenvalue close to 0. True >>> >>> G.compute_laplacian('normalized') >>> G.compute_fourier_basis(recompute=True) - >>> 0 < G.e[0] < G.e[-1] < 2 # Spectrum bounded by [0, 2]. - True - >>> G.e[0] < 1e-10 # Smallest eigenvalue close to 0. + >>> -1e-10 < G.e[0] < 1e-10 < G.e[-1] < 2 # Spectrum in [0, 2]. True """ From c2f693add41a1327b081aab867d109f2f5fef8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 20:23:08 +0200 Subject: [PATCH 280/392] setup.py: version specific dependencies for wheels setup.py is only run when installed from source. The dependencies were previously chosen according to the Python version used when invoking setup.py, i.e. when creating the wheel not installing it. --- .travis.yml | 2 +- README.rst | 4 ++-- setup.py | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index c3f19abb..9a4d170b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,7 +15,7 @@ addons: - xvfb install: - - pip install -U pip + - pip install -U pip setuptools - pip install -U -r requirements.txt - pip install . diff --git a/README.rst b/README.rst index 63737e2d..5cd2d28b 100644 --- a/README.rst +++ b/README.rst @@ -98,8 +98,8 @@ The PyGSP is available on PyPI:: $ pip install pygsp -Note that you will need a recent version of ``pip``. -Please run ``pip install --upgrade pip`` if you get an installation error. +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. Contributing ------------ diff --git a/setup.py b/setup.py index 93755f5f..b42719cb 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import sys from setuptools import setup @@ -24,10 +23,15 @@ 'scipy', 'matplotlib', 'pyqtgraph', - 'PyQt5' if sys.version_info >= (3, 5) else 'PySide', + # PyQt5 is only available on PyPI as wheels for Python 3.5 and up. + 'PyQt5; python_version >= "3.5"', + # No source package for PyQt5 on PyPI, fall back to PySide. + 'PySide; python_version < "3.5"', 'pyopengl', 'scikit-image', - 'pyflann' if sys.version_info.major == 2 else 'pyflann3'], + 'pyflann; python_version == "2.*"', + 'pyflann3; python_version == "3.*"', + ], license="BSD", keywords='graph signal processing', platforms='any', From 397e1c81e206c49b58cf3bad597139d84d223716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 21:25:18 +0200 Subject: [PATCH 281/392] setup: use extras_require instead of requirements.txt --- .readthedocs.yml | 2 ++ .travis.yml | 3 +-- CONTRIBUTING.rst | 6 ++---- requirements.txt | 31 ------------------------------- setup.py | 29 ++++++++++++++++++++++++----- 5 files changed, 29 insertions(+), 42 deletions(-) delete mode 100644 requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml index 19b8a5ca..2fc18eae 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,6 +3,8 @@ formats: python: pip_install: true + extra_requirements: + - doc conda: file: .environment.yml diff --git a/.travis.yml b/.travis.yml index 9a4d170b..8bc35b0f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,7 @@ addons: install: - pip install -U pip setuptools - - pip install -U -r requirements.txt - - pip install . + - pip install -U .[test,doc] script: # - make lint diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 78721dc6..a7cb5ff3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -13,9 +13,8 @@ tests for any new code. The package can be set up (ideally in a virtual environment) for local development with the following:: - $ git clone git@github.com:epfl-lts2/pygsp.git - $ pip install -U -r pygsp/requirements.txt - $ pip install -e pygsp + $ git clone https://github.com/epfl-lts2/pygsp.git + $ pip install -U -e pygsp[test,doc,pkg] You can improve or add functionality in the ``pygsp`` folder, along with corresponding unit tests in ``pygsp/tests/test_*.py`` (with reasonable @@ -45,7 +44,6 @@ Repository organization LICENSE.txt Project license *.rst Important documentation Makefile Targets for make - requirements.txt List of packages installed by pip (strong dep in setup.py) setup.py Meta information about package (published on PyPI) .gitignore Files ignored by the git revision control system .travis.yml Defines testing on Travis continuous integration diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3e27026c..00000000 --- a/requirements.txt +++ /dev/null @@ -1,31 +0,0 @@ -# Required for usage. -numpy -scipy - -# Required for plotting. -matplotlib -pyqtgraph -PySide ; python_version < '3.5' -PyQt5 ; python_version >= '3.5' -pyopengl - -# Required to efficiently deal with images. -scikit-image -pyflann3 - -# Required to build documentation. -sphinx -numpydoc -sphinxcontrib-bibtex -sphinx-rtd-theme - -# Required for testing. -coverage -coveralls - -# Required for packaging. -wheel -twine - -# Required for code style checking. -flake8 diff --git a/setup.py b/setup.py index b42719cb..c89af496 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,13 @@ long_description=open('README.rst').read(), author='EPFL LTS2', url='https://github.com/epfl-lts2/pygsp', - packages=['pygsp', - 'pygsp.graphs', - 'pygsp.graphs.nngraphs', - 'pygsp.filters', - 'pygsp.tests'], + packages=[ + 'pygsp', + 'pygsp.graphs', + 'pygsp.graphs.nngraphs', + 'pygsp.filters', + 'pygsp.tests', + ], package_data={'pygsp': ['data/pointclouds/*.mat']}, test_suite='pygsp.tests.test_all.suite', install_requires=[ @@ -32,6 +34,23 @@ 'pyflann; python_version == "2.*"', 'pyflann3; python_version == "3.*"', ], + extras_require={ + 'test': [ + 'flake8', + 'coverage', + 'coveralls', + ], + 'doc': [ + 'sphinx', + 'numpydoc', + 'sphinxcontrib-bibtex', + 'sphinx-rtd-theme', + ], + 'pkg': [ + 'wheel', + 'twine', + ], + }, license="BSD", keywords='graph signal processing', platforms='any', From 5e27a011e0e9ed8dbbf5431957747c6841a51b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 3 Sep 2017 23:26:52 +0200 Subject: [PATCH 282/392] doc: analysis --> filter --- pygsp/filters/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index ffb07528..6101d17a 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -411,7 +411,7 @@ def compute_frame(self, **kwargs): See also -------- - analysis: more efficient way to filter signals + filter: more efficient way to filter signals Examples -------- From 24c834c076703b4e8ef9f8fdf57248e4ef743b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 00:49:41 +0200 Subject: [PATCH 283/392] complete history --- doc/history.rst | 100 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index 5cd499f7..d0ddaad1 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -2,6 +2,74 @@ History ======= +x.x.x (xxxx-xx-xx) +------------------ + +* Generalized the analysis and synthesis methods into the filter method. +* Signals are now rank-3 tensors of N_NODES x N_SIGNALS x N_FEATURES. +* Filter.evaluate returns a 2D array instead of a list of vectors. +* The differential operator was integrated in the Graph class, as the Fourier + basis and the Laplacian were already. +* Removed the operators package. Transforms and differential operators went to + the Graph class, the localization operator to the Filter class. These are now + easier to use. Reduction became its own module. +* Graph object uses properties to delay the computation (lazy evaluation) of + the Fourier basis (G.U, G.e, G.mu), the estimation of the largest eigenvalue + (G.lmax) and the computation of the differential operator (G.D). A warning is + issued if client code don't trigger computations with G.compute_*. +* Approximations for filtering have been moved in the filters package. +* PointCloud object removed. Functionality integrated in Graph object. +* data_handling module merged into utils. +* Fourier basis computed with eigh instead of svd (faster). +* estimate_lmax uses Lanczos instead of Arnoldi (symmetric sparse). +* Add a seed parameter to various graphs and filters. +* Filter.Nf indicates the number of filters in the filter bank. +* Don't check connectedness on graph creation (can take a lot of time). +* Show plots by default with matplotlib backend. +* Many bug fixes (e.g. Minnesota graph, Meyer filter bank, Heat filter, Mexican + hat filter bank, Gabor filter bank). +* All GitHub issues fixed. + +Plotting: + +* Much better handling of plotting parameters. +* Allows to set a default plotting backend as plotting.BACKEND = 'pyqtgraph'. +* qtg_default=False becomes backend='matplotlib' +* Added coordinates for path, ring, and randomring graphs. +* Set good default plotting parameters for most graphs. +* Allows to plot multiple filters in 1D with set_coordinates('line1D'). +* Allows to pass existing matplotlib axes to the plotting functions. +* Show colorbar with matplotlib. +* Allows to set a 3D view point. + +Documentation: + +* More comprehensive documentation. Notably math definitions for operators. +* Most filters and graphs are plotted in the API documentation. +* List all methods and models at the top with autosummary. +* Useful package and module-level documentation. +* Doctests don't need to import numpy and the pygsp every time. +* Figures are automatically generated when building the documentation. +* Build on RTD with conda and matplotlib 2 (prettier plots). +* Intro and wavelets tutorials were updated. +* Reference guide is completely auto-generated from automodule. +* Added contribution guidelines. +* Documentation reorganization. +* Check that hyperlinks are valid. + +Tests and infrastructure: + +* Start test coverage analysis. +* Much more comprehensive tests. Coverage increased from 40% to 80%. + Many bugs were uncovered. +* Always test with virtual X framebuffer to avoid the opening of lots of + windows. +* Tested on Python 2.7, 3.4, 3.5, 3.6. +* Clean configuration files. +* Not using tox anymore (too painful to maintain multiple Pythons locally). +* Sort out installation of dependencies. Plotting should now work right away. +* Completely migrate development on GitHub. + 0.4.2 (2017-04-27) ------------------ @@ -13,12 +81,15 @@ History * Added routines to compute coordinates for the graphs. * Added fast filtering of ideal band-pass. -* Implemented graph spectrogramms. +* Implemented graph spectrograms. * Added the Barabási-Albert model for graphs. * Renamed PointClouds features. * Various fixes. -0.3.3 (2015-11-27) +0.4.0 (2016-06-17) +------------------ + +0.3.3 (2016-01-27) ------------------ * Refactoring graphs using object programming and fail safe checks. @@ -29,30 +100,29 @@ History * Various fixes. * Finalizing demos for the documentation. -0.2.1 (2015-10-14) +0.3.2 (2016-01-14) +------------------ + +0.3.1 (2016-01-12) +------------------ + +0.3.0 (2015-12-01) +------------------ + +0.2.1 (2015-10-20) ------------------ * Fix bug on pip installation. * Update full documentation. -0.2.0 (2015-10-05) +0.2.0 (2015-10-12) ------------------ * Adding functionalities to match the content of the Matlab GSP Box. * First release of the PyGSP. -0.1.0 (2015-06-02) +0.1.0 (2015-07-02) ------------------ * Main features of the box are present most of the graphs and filters can be used. * The utils and operators modules also have most of their features implemented. - -0.0.2 (2015-04-19) ------------------- - -* Beginning of user tests. - -0.0.1 (2014-10-06) ------------------- - -* Toolbox template release. From 4994d4c4c72ec6774ca8d9cc7590b4f2b243b26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 01:01:34 +0200 Subject: [PATCH 284/392] readme: update --- README.rst | 91 ++++++++++++++-------------------- pygsp/data/readme_example.png | Bin 0 -> 200321 bytes 2 files changed, 36 insertions(+), 55 deletions(-) create mode 100644 pygsp/data/readme_example.png diff --git a/README.rst b/README.rst index 5cd2d28b..52cdeb00 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,3 @@ -.. _about: - ======================================== PyGSP: Graph Signal Processing in Python ======================================== @@ -34,62 +32,45 @@ documentation is available on `Read the Docs `_ and development takes place on `GitHub `_. -This example demonstrates how to create a graph, a filter and analyse a signal on the graph. +The PyGSP facilitates a wide variety of operations on graphs, like computing +their Fourier basis, filtering or interpolating signals, plotting graphs, +signals, and filters. Its core is spectral graph theory, and many of the +provided operations scale to very large graphs. The package includes a wide +range of graphs, from point clouds like the Stanford bunny and the Swiss roll; +to networks like the Minnesota road network; to models for generating random +graphs like stochastic block models, sensor networks, Erdős–Rényi model, +Barabási-Albert model; to simple graphs like the path, the ring, and the grid. +Many filter banks are also provided, e.g. various wavelets like the Mexican +hat, Meyer, Half Cosine; some low-pass filters like the heat kernel and the +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. + +The following example demonstrates how to instantiate a graph and a filter, the +two main objects of the package. >>> from pygsp import graphs, filters >>> G = graphs.Logo() ->>> f = filters.Heat(G) ->>> Sl = f.filter(G.L.todense(), method='chebyshev') - -Features --------- - -This package facilitates graph constructions and give tools to perform signal processing on them. - -A whole list of pre-constructed graphs can be used as well as core functions to create any other graph among which:: - - - Neighest Neighbor Graphs - - Bunny - - Cube - - Sphere - - TwoMoons - - ImgPatches - - Grid2dImgPatches - - Airfoil - - BarabasiAlbert - - Comet - - Community - - DavidSensorNet - - ErdosRenyi - - FullConnected - - Grid2d - - Logo GSP - - LowStretchTree - - Minnesota - - Path - - RandomRegular - - RandomRing - - Ring - - Sensor - - StochasticBlockModel - - SwissRoll - - Torus - -On these graphs, filters can be applied to do signal processing. To this end, there is also a list of predefined filters on this toolbox:: - - - Abspline - - Expwin - - Gabor - - HalfCosine - - Heat - - Held - - Itersine - - MexicanHat - - Meyer - - Papadakis - - Regular - - Simoncelli - - SimpleTf +>>> G.estimate_lmax() +>>> g = filters.Heat(G, tau=100) + +Let's now create a graph signal which a set of three Kronecker deltas. Then +filter it with the above defined filter and look at one step of heat diffusion +on that particular graph. Note how the diffusion follows the local structure! + +>>> import numpy as np +>>> s = np.zeros(G.N) +>>> s[[20, 30, 1090]] = 1 +>>> s = g.filter(s) +>>> G.plot_signal(s, backend='matplotlib') + +.. image:: ../pygsp/data/readme_example.png + :alt: +.. image:: pygsp/data/readme_example.png + :alt: + +Please see the tutorials for more usage examples and the reference guide for an +exhaustive documentation of the API. Enjoy the package! Installation ------------ diff --git a/pygsp/data/readme_example.png b/pygsp/data/readme_example.png new file mode 100644 index 0000000000000000000000000000000000000000..21b160595e8898cc85c214c7eba6d7f595da9584 GIT binary patch literal 200321 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%0A=;@is9A%PC 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 Oxv6|hsZhZp=>Gr$mR|1w literal 0 HcmV?d00001 From f2f5e7615fd486bf3e5d4c1e6a8c04d8d64bfa83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 12:05:36 +0200 Subject: [PATCH 285/392] test_eigendecompositions: larger tol and different sortings Test failed on Rodrigo's machine with MKL (from conda). --- pygsp/tests/test_graphs.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c2321db5..c1e3e7f5 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -92,17 +92,18 @@ def correct_sign(U): V1 = correct_sign(V1.T) V2 = correct_sign(V2.T) - inds = np.argsort(e3)[::-1] + inds3 = np.argsort(e3)[::-1] + inds4 = np.argsort(e4)[::-1] np.testing.assert_allclose(e2, e1) - np.testing.assert_allclose(e3[inds], e1, atol=1e-12) - np.testing.assert_allclose(e4[inds], e1, atol=1e-12) + np.testing.assert_allclose(e3[inds3], e1, atol=1e-12) + np.testing.assert_allclose(e4[inds4], e1, atol=1e-12) np.testing.assert_allclose(e5[::-1], e1, atol=1e-12) np.testing.assert_allclose(e6[::-1], e1, atol=1e-12) - np.testing.assert_allclose(U2, U1) + np.testing.assert_allclose(U2, U1, atol=1e-12) np.testing.assert_allclose(V1, U1, atol=1e-12) np.testing.assert_allclose(V2, U1, atol=1e-12) - np.testing.assert_allclose(U3[:, inds], U1, atol=1e-10) - np.testing.assert_allclose(U4[:, inds], U1, atol=1e-10) + np.testing.assert_allclose(U3[:, inds3], U1, atol=1e-10) + np.testing.assert_allclose(U4[:, inds4], U1, atol=1e-10) np.testing.assert_allclose(U5[:, ::-1], U1, atol=1e-10) np.testing.assert_allclose(U6[:, ::-1], U1, atol=1e-10) From 0206ecb427b588d55778c69ccfd0f689a55f3a71 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Mon, 4 Sep 2017 17:07:17 +0200 Subject: [PATCH 286/392] Adapt Grid2dImgPatches to the new syntax og Grid2d --- pygsp/graphs/nngraphs/grid2dimgpatches.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 027d0336..e5951709 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -33,7 +33,7 @@ class Grid2dImgPatches(Graph): def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): - Gg = Grid2d(*img.shape) + Gg = Grid2d(img.shape[0], img.shape[1]) Gp = ImgPatches(img=img, patch_shape=patch_shape, n_nbrs=n_nbrs) gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), From d28df5ec5136fda56d3a2fae3fb4cd61555cf351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 18:14:43 +0200 Subject: [PATCH 287/392] graphs: remove redundant checks --- pygsp/graphs/graph.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 1e61535b..573d4caa 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -405,12 +405,6 @@ def is_connected(self, recompute=False): if hasattr(self, '_connected') and not recompute: return self._connected - if self.A.shape[0] != self.A.shape[1]: - self.logger.error("Inconsistent shape to test connectedness. " - "Set to False.") - self._connected = False - return self._connected - if self.is_directed(recompute=recompute): adj_matrices = [self.A, self.A.T] else: @@ -469,10 +463,6 @@ def is_directed(self, recompute=False): if hasattr(self, '_directed') and not recompute: return self._directed - if np.diff(self.W.shape)[0]: - raise ValueError("Matrix dimensions mismatch, expecting square " - "matrix.") - self._directed = np.abs(self.W - self.W.T).sum() != 0 return self._directed From 9b0e7fff18c22efb6ede2c86517cc5339295cde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 18:30:47 +0200 Subject: [PATCH 288/392] graphs: don't count edges two times if undirected. Be consistent with the size of the differential operator. --- doc/tutorials/intro.rst | 7 ++++--- pygsp/graphs/difference.py | 12 ++++++------ pygsp/graphs/graph.py | 15 ++++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index e5107158..c7e38d00 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -42,9 +42,10 @@ follows. >>> W = rs.uniform(size=(30, 30)) # Full graph. >>> W[W < 0.93] = 0 # Sparse graph. >>> W = W + W.T # Symmetric graph. + >>> np.fill_diagonal(W, 0) # No self-loops. >>> G = graphs.Graph(W) >>> print('{} nodes, {} edges'.format(G.N, G.Ne)) - 30 nodes, 122 edges + 30 nodes, 60 edges The :class:`pygsp.graphs.Graph` class we just instantiated is the base class for all graph objects, which offers many methods and attributes. @@ -96,8 +97,8 @@ smoothness of a signal. :context: close-figs >>> G.compute_differential_operator() - >>> G.D.shape # Not G.Ne / 2 because of self-loops. - (62, 30) + >>> G.D.shape + (60, 30) .. note:: Note that we called :meth:`pygsp.graphs.Graph.compute_fourier_basis` and diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index 30637ab6..eab3f5c4 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -47,9 +47,9 @@ def compute_differential_operator(self): -------- >>> G = graphs.Logo() >>> G.N, G.Ne - (1130, 6262) + (1130, 3131) >>> G.compute_differential_operator() - >>> G.D.shape == (G.Ne//2, G.N) + >>> G.D.shape == (G.Ne, G.N) True """ @@ -103,7 +103,7 @@ def grad(self, s): -------- >>> G = graphs.Logo() >>> G.N, G.Ne - (1130, 6262) + (1130, 3131) >>> s = np.random.normal(size=G.N) >>> s_grad = G.grad(s) >>> s_div = G.div(s_grad) @@ -143,12 +143,12 @@ def div(self, s): -------- >>> G = graphs.Logo() >>> G.N, G.Ne - (1130, 6262) - >>> s = np.random.normal(size=G.Ne//2) # Symmetric weight matrix. + (1130, 3131) + >>> s = np.random.normal(size=G.Ne) >>> s_div = G.div(s) >>> s_grad = G.grad(s_div) """ - if self.Ne != 2 * s.shape[0]: + if self.Ne != s.shape[0]: raise ValueError('Signal length should be the number of edges.') return self.D.T.dot(s) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 573d4caa..7da71063 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -40,7 +40,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): Ne : int the number of edges / links in the graph, i.e. connections between nodes. - W : ndarray + W : sparse matrix or ndarray 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 @@ -82,10 +82,17 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.N = W.shape[0] self.W = sparse.lil_matrix(W) + + # Don't count edges two times if undirected. + # Be consistent with the size of the differential operator. + if self.is_directed(): + self.Ne = self.W.nnz + else: + self.Ne = sparse.tril(W).nnz + self.check_weights() self.A = self.W > 0 - self.Ne = self.W.nnz self.d = np.asarray(self.A.sum(axis=1)).squeeze() assert self.d.ndim == 1 self.gtype = gtype @@ -703,9 +710,7 @@ def get_edge_list(self): # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) - assert v_in.size == v_out.size == weights.size - assert self.Ne >= v_in.size # graph might have self-loops - + assert self.Ne == v_in.size == v_out.size == weights.size return v_in, v_out, weights def sanitize_signal(self, s): From 495830dc4a5c89521b9af6d5a96a8097af46c187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 4 Sep 2017 23:41:12 +0200 Subject: [PATCH 289/392] readme: update --- README.rst | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 52cdeb00..bc283cc7 100644 --- a/README.rst +++ b/README.rst @@ -46,17 +46,18 @@ 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. -The following example demonstrates how to instantiate a graph and a filter, the -two main objects of the package. +The following demonstrates how to instantiate a graph and a filter, the two +main objects of the package. >>> from pygsp import graphs, filters >>> G = graphs.Logo() >>> G.estimate_lmax() >>> g = filters.Heat(G, tau=100) -Let's now create a graph signal which a set of three Kronecker deltas. Then -filter it with the above defined filter and look at one step of heat diffusion -on that particular graph. Note how the diffusion follows the local structure! +Let's now create a graph signal: a set of three Kronecker deltas for that +example. We can now look at one step of heat diffusion by filtering the deltas +with the above defined filter. Note how the diffusion follows the local +structure! >>> import numpy as np >>> s = np.zeros(G.N) From 33816873c3c6f82d1585e43528b4c495c99e0bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Sep 2017 20:04:39 +0200 Subject: [PATCH 290/392] ErdosRenyi: error if the graph cannot be connected --- pygsp/graphs/erdosrenyi.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 0e03f5cf..e26813d4 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -9,22 +9,22 @@ class ErdosRenyi(Graph): r"""Erdos Renyi graph. - The Erdos Renyi graph is constructed by connecting nodes randomly. Each - edge is included in the graph with probability p independent from every + The Erdos Renyi graph is constructed by randomly connecting nodes. Each + edge is included in the graph with probability p, independently from any other edge. All edge weights are equal to 1. Parameters ---------- N : int - Number of nodes (default is 100) + Number of nodes (default is 100). p : float - Probability of connection of a node with another + Probability to connect a node with another one. connected : bool Force the graph to be connected (default is False). directed : bool Define if the graph is directed (default is False). max_iter : int - Maximum number of try to connect the graph (default is 10). + Maximum number of trials to connect the graph (default is 10). Examples -------- @@ -42,7 +42,8 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, M = int(N * (N-1) if directed else N * (N-1) / 2) nb_elem = int(p * M) - for i in range(max_iter): + nb_iter = 0 + while True: indices = np.random.permutation(M)[:nb_elem] if directed: @@ -58,5 +59,11 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, if not connected or self.is_connected(recompute=True): break - - super(ErdosRenyi, self).__init__(W=self.W, gtype=u"Erdös Renyi", **kwargs) + nb_iter += 1 + if nb_iter > max_iter: + raise ValueError('The graph could not be connected after {} ' + 'trials. Increase the connection probability ' + 'or the number of trials.'.format(max_iter)) + + super(ErdosRenyi, self).__init__(W=self.W, gtype=u"Erdös Renyi", + **kwargs) From a3bf5473e7df5dd130f1c558d294400c51269bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Sep 2017 20:47:03 +0200 Subject: [PATCH 291/392] ErdosRenyi: 0 and 1 are valid probabilities --- pygsp/graphs/erdosrenyi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index e26813d4..7e3126d8 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -36,7 +36,7 @@ def __init__(self, N=100, p=0.1, connected=False, directed=False, max_iter=10, **kwargs): self.p = p - if not 0 < p < 1: + if not 0 <= p <= 1: raise ValueError('Probability p should be in [0, 1].') M = int(N * (N-1) if directed else N * (N-1) / 2) From 0cfe9abb9cde1ad3e2b58999db349f1210d71753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 19 Sep 2017 23:30:49 +0200 Subject: [PATCH 292/392] symmetrize: do not modify the original array --- pygsp/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 80ce0dfa..e51f8745 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -256,7 +256,7 @@ def symmetrize(W, symmetrize_type='average'): else: # numpy boolean subtract is deprecated in python 3 mask = np.logical_xor(np.logical_or(A, A.T), A).astype('float') - W += mask.multiply(W.T) if sparse_flag else (mask * W.T) + W = W + (mask.multiply(W.T) if sparse_flag else (mask * W.T)) return (W + W.T) / 2. # Resolve ambiguous entries else: raise ValueError("Unknown symmetrization type.") From af95adcc0a3e411f62c1eb4522502c59d30a8cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 15:24:27 +0200 Subject: [PATCH 293/392] set_coordinates: random seed for reproducible spring layout --- pygsp/graphs/graph.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7da71063..bc25187b 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -812,7 +812,8 @@ def plot_spectrogram(self, **kwargs): plotting.plot_spectrogram(self, **kwargs) def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], - iterations=50, scale=1.0, center=None): + 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. @@ -824,24 +825,23 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], self.logger.error('Spring coordinates: center has wrong size.') center = np.zeros((1, dim)) - dom_size = 1. - - if pos is not None: + 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) - shape = (self.N, dim) - pos_arr = np.random.random(shape) * dom_size + center + 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]) - else: - pos_arr = None 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) + fixed, iterations, seed) if len(fixed) == 0: pos = _rescale_layout(pos, scale=scale) + center @@ -849,7 +849,7 @@ def _fruchterman_reingold_layout(self, dim=2, k=None, pos=None, fixed=[], return pos -def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations): +def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations, seed): # Position nodes in adjacency matrix A using Fruchterman-Reingold nnodes = A.shape[0] @@ -861,7 +861,7 @@ def _sparse_fruchterman_reingold(A, dim, k, pos, fixed, iterations): if pos is None: # random initial positions - pos = np.random.uniform(size=(nnodes, dim)) + pos = np.random.RandomState(seed).uniform(size=(nnodes, dim)) # optimal distance between nodes if k is None: From 919c417f7bcec0e35e92f37a9944bbdcccbae3f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 12:06:38 +0200 Subject: [PATCH 294/392] RandomRegular: test generated graph --- pygsp/tests/test_graphs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index c1e3e7f5..36d02bdd 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -229,7 +229,10 @@ def test_lowstretchtree(self): graphs.LowStretchTree() def test_randomregular(self): - graphs.RandomRegular() + k = 6 + G = graphs.RandomRegular(k=k) + np.testing.assert_equal(G.W.sum(0), k) + np.testing.assert_equal(G.W.sum(1), k) def test_ring(self): graphs.Ring() From 7a7451d32fabdf7948e96df9fedf34049b1381d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 15:32:27 +0200 Subject: [PATCH 295/392] community: use explicit parameters --- pygsp/graphs/community.py | 97 +++++++++++++++++++++----------------- pygsp/tests/test_graphs.py | 2 +- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index b656d894..8c60083e 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import collections import copy @@ -16,26 +18,34 @@ class Community(Graph): Parameters ---------- N : int - Number of nodes (default = 256) + Number of nodes (default = 256). Nc : int (optional) - Number of communities (default = :math:`round(\sqrt{N}/2)`) + Number of communities (default = :math:`\lfloor \sqrt{N}/2 \rceil`). min_comm : int (optional) - Minimum size of the communities (default = round(N/Nc/3)) + Minimum size of the communities + (default = :math:`\lfloor N/Nc/3 \rceil`). min_deg : int (optional) - Minimum degree of each node (default = 0, NOT IMPLEMENTED YET) + NOT IMPLEMENTED. Minimum degree of each node (default = 0). comm_sizes : int (optional) - Size of the communities (default = random) + Size of the communities (default = random). size_ratio : float (optional) - Ratio between the radius of world and the radius of communities (default = 1) + Ratio between the radius of world and the radius of communities + (default = 1). world_density : float (optional) - Probability of a random edge between two different communities (default = 1/N) + Probability of a random edge between two different communities + (default = 1/N). comm_density : float (optional) - Probability of a random edge inside any community (default = None, not used if None) + Probability of a random edge inside any community (default = None, + which implies k_neigh or epsilon will be used to determine + intra-edges). k_neigh : int (optional) - Number of intra-community connections (default = None, not used if None or comm_density is defined) + Number of intra-community connections. + Not used if comm_density is defined (default = None, which implies + comm_density or epsilon will be used to determine intra-edges). epsilon : float (optional) - Max distance at which two nodes sharing a community are connected - (default = :math:`sqrt(2\sqrt{N})/2`, not used if k_neigh or comm_density is defined) + Largest distance at which two nodes sharing a community are connected. + Not used if k_neigh or comm_density is defined + (default = :math:`\sqrt{2\sqrt{N}}/2`). Examples -------- @@ -43,34 +53,33 @@ class Community(Graph): >>> graphs.Community().plot() """ - def __init__(self, N=256, **kwargs): - - # Parameter initialisation # - N = int(N) - Nc = int(kwargs.pop('Nc', int(round(np.sqrt(N)/2.)))) - min_comm = int(kwargs.pop('min_comm', int(round(N / (3. * Nc))))) - min_deg = int(kwargs.pop('min_deg', 0)) - comm_sizes = kwargs.pop('comm_sizes', np.array([])) - size_ratio = float(kwargs.pop('size_ratio', 1.)) - world_density = float(kwargs.pop('world_density', 1. / N)) - world_density = world_density if 0 <= world_density <= 1 else 1. / N - comm_density = kwargs.pop('comm_density', None) - k_neigh = kwargs.pop('k_neigh', None) - epsilon = float(kwargs.pop('epsilon', np.sqrt(2 * np.sqrt(N)) / 2)) + def __init__(self, + N=256, + Nc=None, + min_comm=None, + min_deg=0, + comm_sizes=None, + size_ratio=1, + world_density=None, + comm_density=None, + k_neigh=None, + epsilon=None, + **kwargs): + + if Nc is None: + Nc = int(round(np.sqrt(N) / 2)) + if min_comm is None: + min_comm = int(round(N / (3 * Nc))) + if world_density is None: + world_density = 1 / N + if not 0 <= world_density <= 1: + raise ValueError('World density should be in [0, 1].') + if epsilon is None: + epsilon = np.sqrt(2 * np.sqrt(N)) / 2 self.logger = utils.build_logger(__name__, **kwargs) w_data = [[], [[], []]] - try: - if len(comm_sizes) > 0: - if np.sum(comm_sizes) != N: - raise ValueError('The sum of the community sizes has to be equal to N.') - if len(comm_sizes) != Nc: - raise ValueError('The length of the community sizes has to be equal to Nc.') - - except TypeError: - raise TypeError('comm_sizes expected to be a list or array, got {}'.format(type(comm_sizes))) - if min_comm * Nc > N: raise ValueError('The constraint on minimum size for communities is unsolvable.') @@ -78,11 +87,15 @@ def __init__(self, N=256, **kwargs): 'world_density': world_density, 'min_comm': min_comm} # Communities construction # - if comm_sizes.shape[0] == 0: + if comm_sizes is None: mandatory_labels = np.tile(np.arange(Nc), (min_comm,)) # min_comm labels for each of the Nc communities remaining_labels = np.random.choice(Nc, N - min_comm * Nc) # random choice for the remaining labels info['node_com'] = np.sort(np.concatenate((mandatory_labels, remaining_labels))) else: + if len(comm_sizes) != Nc: + raise ValueError('There should be Nc community sizes.') + if np.sum(comm_sizes) != N: + raise ValueError('The sum of community sizes should be N.') # create labels based on the constraint given for the community sizes. No random assignation here. info['node_com'] = np.concatenate([[val] * cnt for (val, cnt) in enumerate(comm_sizes)]) @@ -91,16 +104,16 @@ def __init__(self, N=256, **kwargs): info['world_rad'] = size_ratio * np.sqrt(N) # Intra-community edges construction # - if comm_density: + if comm_density is not None: # random picking edges following the community density (same for all communities) comm_density = float(comm_density) comm_density = comm_density if 0. <= comm_density <= 1. else 0.1 info['comm_density'] = comm_density self.logger.info('Constructed using community density = {}'.format(comm_density)) - elif k_neigh: + elif k_neigh is not None: # k-NN among the nodes in the same community (same k for all communities) - k_neigh = int(k_neigh) - k_neigh = k_neigh if k_neigh > 0 else 10 + if k_neigh < 0: + raise ValueError('k_neigh cannot be negative.') info['k_neigh'] = k_neigh self.logger.info('Constructed using K-NN with k = {}'.format(k_neigh)) else: @@ -128,7 +141,7 @@ def __init__(self, N=256, **kwargs): com_siz = info['comm_sizes'][i] M = com_siz * (com_siz - 1) / 2 - if comm_density: + if comm_density is not None: nb_edges = int(comm_density * M) tril_ind = np.tril_indices(com_siz, -1) indices = np.random.permutation(int(M))[:nb_edges] @@ -137,7 +150,7 @@ def __init__(self, N=256, **kwargs): w_data[1][0] += [first_node + tril_ind[1][elem] for elem in indices] w_data[1][1] += [first_node + tril_ind[0][elem] for elem in indices] - elif k_neigh: + elif k_neigh is not None: comm_coords = coords[first_node:first_node + com_siz] kdtree = spatial.KDTree(comm_coords) __, indices = kdtree.query(comm_coords, k=k_neigh + 1) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 36d02bdd..eca7793d 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -242,7 +242,7 @@ def test_community(self): graphs.Community() graphs.Community(comm_density=0.2) graphs.Community(k_neigh=5) - graphs.Community(world_density=0.8) + graphs.Community(N=100, Nc=3, comm_sizes=[20, 50, 30]) def test_minnesota(self): graphs.Minnesota() From c758aab6dffd6d7e44f42610689be80d63b80f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 16:37:39 +0200 Subject: [PATCH 296/392] seed for reproducible graphs --- pygsp/graphs/barabasialbert.py | 11 +++++++--- pygsp/graphs/community.py | 14 +++++++----- pygsp/graphs/davidsensornet.py | 6 ++++-- pygsp/graphs/nngraphs/cube.py | 32 ++++++++++++++++++---------- pygsp/graphs/nngraphs/sphere.py | 21 ++++++++++++++---- pygsp/graphs/nngraphs/twomoons.py | 17 +++++++++------ pygsp/graphs/randomregular.py | 11 ++++++---- pygsp/graphs/stochasticblockmodel.py | 10 ++++++--- pygsp/graphs/swissroll.py | 13 ++++++----- 9 files changed, 91 insertions(+), 44 deletions(-) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 959bebc1..944e7fcd 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -28,25 +28,30 @@ class BarabasiAlbert(Graph): m : int Number of connections at each step (default is 1) m can never be larger than m0. + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- >>> G = graphs.BarabasiAlbert() """ - def __init__(self, N=1000, m0=1, m=1, **kwargs): + def __init__(self, N=1000, m0=1, m=1, seed=None, **kwargs): if m > m0: raise ValueError('Parameter m cannot be above parameter m0.') W = sparse.lil_matrix((N, N)) + rs = np.random.RandomState(seed) for i in range(m0, N): distr = W.sum(axis=1) distr += np.concatenate((np.ones((i, 1)), np.zeros((N-i, 1)))) - connections = np.random.choice(N, size=m, replace=False, p=np.ravel(distr/distr.sum())) + connections = rs.choice( + N, size=m, replace=False, p=np.ravel(distr / distr.sum())) for elem in connections: W[elem, i] = 1 W[i, elem] = 1 - super(BarabasiAlbert, self).__init__(W=W, gtype=u"Barabasi-Albert", **kwargs) + super(BarabasiAlbert, self).__init__( + W=W, gtype=u"Barabasi-Albert", **kwargs) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 8c60083e..aa407064 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -46,6 +46,8 @@ class Community(Graph): Largest distance at which two nodes sharing a community are connected. Not used if k_neigh or comm_density is defined (default = :math:`\sqrt{2\sqrt{N}}/2`). + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -64,6 +66,7 @@ def __init__(self, comm_density=None, k_neigh=None, epsilon=None, + seed=None, **kwargs): if Nc is None: @@ -76,6 +79,7 @@ def __init__(self, raise ValueError('World density should be in [0, 1].') if epsilon is None: epsilon = np.sqrt(2 * np.sqrt(N)) / 2 + rs = np.random.RandomState(seed) self.logger = utils.build_logger(__name__, **kwargs) w_data = [[], [[], []]] @@ -89,7 +93,7 @@ def __init__(self, # Communities construction # if comm_sizes is None: mandatory_labels = np.tile(np.arange(Nc), (min_comm,)) # min_comm labels for each of the Nc communities - remaining_labels = np.random.choice(Nc, N - min_comm * Nc) # random choice for the remaining labels + remaining_labels = rs.choice(Nc, N - min_comm * Nc) # random choice for the remaining labels info['node_com'] = np.sort(np.concatenate((mandatory_labels, remaining_labels))) else: if len(comm_sizes) != Nc: @@ -126,7 +130,7 @@ def __init__(self, np.cos(2 * np.pi * np.arange(1, Nc + 1) / Nc), np.sin(2 * np.pi * np.arange(1, Nc + 1) / Nc)))) - coords = np.random.rand(N, 2) # nodes' coordinates inside the community + coords = rs.rand(N, 2) # nodes' coordinates inside the community 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]) @@ -144,7 +148,7 @@ def __init__(self, if comm_density is not None: nb_edges = int(comm_density * M) tril_ind = np.tril_indices(com_siz, -1) - indices = np.random.permutation(int(M))[:nb_edges] + indices = rs.permutation(int(M))[:nb_edges] w_data[0] += [1] * nb_edges w_data[1][0] += [first_node + tril_ind[1][elem] for elem in indices] @@ -181,12 +185,12 @@ def __init__(self, # use regression sampling inter_edges = set() while len(inter_edges) < nb_edges: - new_point = np.random.randint(0, N, 2) + new_point = rs.randint(0, N, 2) if info['node_com'][min(new_point)] != info['node_com'][max(new_point)]: inter_edges.add((min(new_point), max(new_point))) else: # use random permutation - indices = np.random.permutation(int(M))[:nb_edges] + indices = rs.permutation(int(M))[:nb_edges] all_points, first_col = [], 0 for i in range(Nc - 1): nb_col = info['comm_sizes'][i] diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index dbd7ed0f..7fc2c56d 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -15,6 +15,8 @@ class DavidSensorNet(Graph): Number of vertices (default = 64). Values of 64 and 500 yield pre-computed and saved graphs. Other values yield randomly generated graphs. + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -23,7 +25,7 @@ class DavidSensorNet(Graph): """ - def __init__(self, N=64): + def __init__(self, N=64, seed=None): if N == 64: data = utils.loadmat('pointclouds/david64') assert data['N'][0, 0] == N @@ -37,7 +39,7 @@ def __init__(self, N=64): coords = data['coords'] else: - coords = np.random.rand(N, 2) + coords = np.random.RandomState(seed).rand(N, 2) target_dist_cutoff = -0.125 * N / 436.075 + 0.2183 T = 0.6 diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index e982155f..714176ba 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -19,49 +19,59 @@ class Cube(NNGraph): sampling : string Variance of the distance kernel (default = 'random') (Can now only be 'random') + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- >>> import matplotlib.pyplot as plt >>> fig = plt.figure(figsize=(10, 8)) >>> ax = fig.add_subplot(111, projection='3d') - >>> graphs.Cube().plot(ax=ax) + >>> graphs.Cube(seed=42).plot(ax=ax) """ - def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling="random", **kwargs): + def __init__(self, + radius=1, + nb_pts=300, + nb_dim=3, + sampling='random', + seed=None, + **kwargs): + self.radius = radius self.nb_pts = nb_pts self.nb_dim = nb_dim self.sampling = sampling + rs = np.random.RandomState(seed) if self.nb_dim > 3: raise NotImplementedError("Dimension > 3 not supported yet !") if self.sampling == "random": if self.nb_dim == 2: - pts = np.random.rand(self.nb_pts, self.nb_dim) + pts = rs.rand(self.nb_pts, self.nb_dim) elif self.nb_dim == 3: n = self.nb_pts // 6 pts = np.zeros((n*6, 3)) - pts[:n, 1:] = np.random.rand(n, 2) + pts[:n, 1:] = rs.rand(n, 2) pts[n:2*n, :] = np.concatenate((np.ones((n, 1)), - np.random.rand(n, 2)), + rs.rand(n, 2)), axis=1) - pts[2*n:3*n, :] = np.concatenate((np.random.rand(n, 1), + pts[2*n:3*n, :] = np.concatenate((rs.rand(n, 1), np.zeros((n, 1)), - np.random.rand(n, 1)), + rs.rand(n, 1)), axis=1) - pts[3*n:4*n, :] = np.concatenate((np.random.rand(n, 1), + pts[3*n:4*n, :] = np.concatenate((rs.rand(n, 1), np.ones((n, 1)), - np.random.rand(n, 1)), + rs.rand(n, 1)), axis=1) - pts[4*n:5*n, :2] = np.random.rand(n, 2) - pts[5*n:6*n, :] = np.concatenate((np.random.rand(n, 2), + pts[4*n:5*n, :2] = rs.rand(n, 2) + pts[5*n:6*n, :] = np.concatenate((rs.rand(n, 2), np.ones((n, 1))), axis=1) diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index e1218bcd..3e9dc232 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -19,29 +19,42 @@ class Sphere(NNGraph): sampling : sting Variance of the distance kernel (default = 'random') (Can now only be 'random') + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- >>> import matplotlib.pyplot as plt >>> fig = plt.figure(figsize=(10, 8)) >>> ax = fig.add_subplot(111, projection='3d') - >>> graphs.Sphere().plot(ax=ax) + >>> graphs.Sphere(seed=42).plot(ax=ax) """ - def __init__(self, radius=1, nb_pts=300, nb_dim=3, sampling='random', **kwargs): + def __init__(self, + radius=1, + nb_pts=300, + nb_dim=3, + sampling='random', + seed=None, + **kwargs): + self.radius = radius self.nb_pts = nb_pts self.nb_dim = nb_dim self.sampling = sampling if self.sampling == 'random': - pts = np.random.normal(0, 1, (self.nb_pts, self.nb_dim)) + + rs = np.random.RandomState(seed) + pts = rs.normal(0, 1, (self.nb_pts, self.nb_dim)) for i in range(self.nb_pts): pts[i] /= np.linalg.norm(pts[i]) + else: - raise ValueError('Unknow sampling!') + + raise ValueError('Unknown sampling!') plotting = { 'vertex_size': 80, diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index f55e57d3..a2da5fc5 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -31,6 +31,8 @@ class TwoMoons(NNGraph): d : float Distance of the two moons (default = 0.5) Only valid for moontype == 'synthesized'. + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -39,11 +41,12 @@ class TwoMoons(NNGraph): """ - def _create_arc_moon(self, N, sigmad, d, number): - phi = np.random.rand(N, 1) * np.pi + def _create_arc_moon(self, N, sigmad, d, number, seed): + rs = np.random.RandomState(seed) + phi = rs.rand(N, 1) * np.pi r = 1 - rb = sigmad * np.random.normal(size=(N, 1)) - ab = np.random.rand(N, 1) * 2 * np.pi + rb = sigmad * rs.normal(size=(N, 1)) + ab = rs.rand(N, 1) * 2 * np.pi b = rb * np.exp(1j * ab) bx = np.real(b) by = np.imag(b) @@ -58,7 +61,7 @@ def _create_arc_moon(self, N, sigmad, d, number): return np.concatenate((moonx, moony), axis=1) def __init__(self, moontype='standard', dim=2, sigmag=0.05, - N=400, sigmad=0.07, d=0.5): + N=400, sigmad=0.07, d=0.5, seed=None): if moontype == 'standard': gtype = 'Two Moons standard' @@ -72,8 +75,8 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, N1 = N // 2 N2 = N - N1 - coords1 = self._create_arc_moon(N1, sigmad, d, 1) - coords2 = self._create_arc_moon(N2, sigmad, d, 2) + coords1 = self._create_arc_moon(N1, sigmad, d, 1, seed) + coords2 = self._create_arc_moon(N2, sigmad, d, 2, seed) Xin = np.concatenate((coords1, coords2)) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 10b71481..fc8689e0 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -22,6 +22,8 @@ class RandomRegular(Graph): Number of connections, or degree, of each node (default is 6) maxIter : int Maximum number of iterations (default is 10) + seed : int + Seed for the random number generator (for reproducible graphs). Notes ----- @@ -40,11 +42,13 @@ class RandomRegular(Graph): """ - def __init__(self, N=64, k=6, maxIter=10, **kwargs): + def __init__(self, N=64, k=6, maxIter=10, seed=None, **kwargs): self.k = k self.logger = utils.build_logger(__name__, **kwargs) + rs = np.random.RandomState(seed) + # continue until a proper graph is formed if (N * k) % 2 == 1: raise ValueError("input error: N*d must be even!") @@ -58,7 +62,6 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): edgesTested = 0 repetition = 1 - # check that there are no loops nor parallel edges while np.size(U) and repetition < maxIter: edgesTested += 1 @@ -68,8 +71,8 @@ def __init__(self, N=64, k=6, maxIter=10, **kwargs): "{}/{}.".format(edgesTested, N*k/2)) # chose at random 2 half edges - i1 = np.random.randint(0, np.shape(U)[0]) - i2 = np.random.randint(0, np.shape(U)[0]) + i1 = rs.randint(0, np.shape(U)[0]) + i2 = rs.randint(0, np.shape(U)[0]) v1 = U[i1] v2 = U[i2] diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 52327dd7..301043e6 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -38,6 +38,8 @@ class StochasticBlockModel(Graph): force the graph to be undirected. Default True. no_self_loop : bool force the graph to have no self loop. Default True. + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -46,10 +48,12 @@ class StochasticBlockModel(Graph): """ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, - undirected=True, no_self_loop=True, **kwargs): + undirected=True, no_self_loop=True, seed=None, **kwargs): + + rs = np.random.RandomState(seed) if z is None: - z = np.random.randint(0, k, N) + z = rs.randint(0, k, N) if M is None: if isinstance(p, float): @@ -80,7 +84,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, for _ in range(N**2): if nb_row != nb_col or not no_self_loop: if nb_row > nb_col or not undirected: - if np.random.rand() < M[z[nb_row], z[nb_col]]: + if rs.uniform() < M[z[nb_row], z[nb_col]]: csr_data.append(1) csr_i.append(nb_row) csr_j.append(nb_col) diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 03d75ae8..e281ccec 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -28,22 +28,25 @@ class SwissRoll(Graph): srtype : str Swiss roll Type, possible arguments are 'uniform' or 'classic' (default = 'uniform') + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- >>> import matplotlib - >>> graphs.SwissRoll().plot() + >>> graphs.SwissRoll(seed=42).plot() """ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, - noise=False, srtype='uniform'): + noise=False, srtype='uniform', seed=None): if s is None: s = np.sqrt(2. / N) - y1 = np.random.rand(N) - y2 = np.random.rand(N) + rs = np.random.RandomState(seed) + y1 = rs.rand(N) + y2 = rs.rand(N) if srtype == 'uniform': tt = np.sqrt((b * b - a * a) * y1 + a * a) @@ -57,7 +60,7 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, x = np.array((tt * np.cos(tt), 21 * y2, tt * np.sin(tt))) if noise: - x += np.random.randn(*x.shape) + x += rs.randn(*x.shape) self.x = x self.dim = dim From 4ed83e3c3c85960d901cd485233b874bd844178d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 16:47:42 +0200 Subject: [PATCH 297/392] SBM: fix self loops when undirected=True --- pygsp/graphs/stochasticblockmodel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 301043e6..5dbe5a0f 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -62,8 +62,8 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, p = np.array(p) if p.shape != (k, ): - raise ValueError('Optional parameter p is neither a scalar nor' - 'a vector of length k.') + raise ValueError('Optional parameter p is neither a scalar ' + 'nor a vector of length k.') if q is None: q = 0.3 / k @@ -73,8 +73,8 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, q = np.array(q) if q.shape != (k, k): - raise ValueError('Optional parameter q is neither a scalar nor' - 'a matrix of size k x k.') + raise ValueError('Optional parameter q is neither a scalar ' + 'nor a matrix of size k x k.') M = q M.flat[::k+1] = p # edit the diagonal terms @@ -83,7 +83,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, csr_data, csr_i, csr_j = [], [], [] for _ in range(N**2): if nb_row != nb_col or not no_self_loop: - if nb_row > nb_col or not undirected: + if nb_row >= nb_col or not undirected: if rs.uniform() < M[z[nb_row], z[nb_col]]: csr_data.append(1) csr_i.append(nb_row) From efe203db38fc6a51b42321696e951338e3de20bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 17:10:47 +0200 Subject: [PATCH 298/392] plotting: show eigenvalues as grey bars instead of crosses --- pygsp/plotting.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index f246e3a1..04b5b0de 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -379,21 +379,22 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, if show_sum is None: show_sum = filters.Nf > 1 + if plot_eigenvalues: + for e in G.e: + ax.axvline(x=e, color=[0.9]*3, linewidth=1) + x = np.linspace(0, G.lmax, npoints) y = filters.evaluate(x).T ax.plot(x, y, linewidth=line_width) - if plot_eigenvalues: - ax.plot(G.e, np.zeros(G.N), 'xk', markeredgewidth=x_width, - markersize=x_size) - # TODO: plot highlighted eigenvalues if show_sum: ax.plot(x, np.sum(y**2, 1), 'k', linewidth=line_width) - ax.set_xlabel("laplacian's eigenvalues / graph frequencies") - ax.set_ylabel('filter response') + ax.set_ylim(-0.1, 1.1) + ax.set_xlabel("$\lambda$: laplacian's eigenvalues / graph frequencies") + ax.set_ylabel('$\hat{g}(\lambda)$: filter response') def plot_signal(G, signal, backend=None, **kwargs): From d13760ad970373e3c2cc68c80aef5bbd0175bd42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 17:43:35 +0200 Subject: [PATCH 299/392] symmetrize: add new methods and use them more --- pygsp/graphs/grid2d.py | 7 +- pygsp/graphs/nngraphs/imgpatches.py | 2 +- pygsp/graphs/nngraphs/nngraph.py | 2 +- pygsp/graphs/randomring.py | 5 +- pygsp/graphs/sensor.py | 4 +- pygsp/graphs/stochasticblockmodel.py | 7 +- pygsp/tests/test_utils.py | 9 +++ pygsp/utils.py | 96 ++++++++++++++++++++-------- 8 files changed, 90 insertions(+), 42 deletions(-) diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 5e8782c7..deff5e82 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -35,14 +35,13 @@ def __init__(self, N1=16, N2=None, **kwargs): # looping through all the grid points: diag_1 = np.ones(N - 1) diag_1[(N2 - 1)::N2] = 0 - stride = N2 - diag_2 = np.ones((N - stride,)) + diag_2 = np.ones(N - N2) W = sparse.diags(diagonals=[diag_1, diag_2], - offsets=[-1, -stride], + offsets=[-1, -N2], shape=(N, N), format='csr', dtype='float') - W = utils.symmetrize(W, symmetrize_type='full') + W = utils.symmetrize(W, method='tril') x = np.kron(np.ones((N1, 1)), (np.arange(N2)/float(N2)).reshape(N2, 1)) y = np.kron(np.ones((N2, 1)), np.arange(N1)/float(N1)).reshape(N, 1) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 5ba64b07..f80c4b77 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -29,7 +29,7 @@ class ImgPatches(NNGraph): """ def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, use_flann=True, - dist_type='euclidean', symmetrize_type='full', **kwargs): + dist_type='euclidean', symmetrize_type='fill', **kwargs): X = utils.extract_patches(img, patch_shape=patch_shape) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index ae4fa3fe..7bf20e00 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -169,7 +169,7 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, # Enforce symmetry. Note that checking symmetry with # np.abs(W - W.T).sum() is as costly as the symmetrization itself. - W = utils.symmetrize(W, symmetrize_type=symmetrize_type) + W = utils.symmetrize(W, method=symmetrize_type) super(NNGraph, self).__init__(W=W, gtype=gtype, plotting=plotting, coords=Xout, **kwargs) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index e7f1733d..65a4bd06 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -3,6 +3,7 @@ import numpy as np from scipy import sparse +from pygsp import utils from . import Graph # prevent circular import in Python < 3.5 @@ -36,8 +37,8 @@ def __init__(self, N=64, seed=None, **kwargs): W = sparse.csc_matrix((weight, (inds_i, inds_j)), shape=(N, N)) W = W.tolil() - W[N-1, 0] = weight_end - W = W + W.T + W[0, N-1] = weight_end + W = utils.symmetrize(W, method='triu') angle = position * 2 * np.pi coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 6166f2fe..94a668d7 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -61,7 +61,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, W, coords = self._create_weight_matrix(N, distribute, regular, Nc) W = sparse.lil_matrix(W) - W = (W + W.T) / 2. + W = utils.symmetrize(W, method='average') gtype = 'regular sensor' if self.regular else 'sensor' @@ -82,7 +82,7 @@ def _get_nc_connection(self, W, param_nc): W[i, ind] = val l[ind] = 0 - W = (W + W.T)/2. + W = utils.symmetrize(W, method='average') return W diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 5dbe5a0f..8da8a0a7 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -3,6 +3,7 @@ import numpy as np from scipy import sparse +from pygsp import utils from . import Graph # prevent circular import in Python < 3.5 @@ -97,11 +98,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, W = sparse.csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) if undirected: - W = W + W.T - - if not no_self_loop: - # avoid doubling the self loops with the above sum - W[range(N), range(N)] = (W.diagonal() == 2) + W = utils.symmetrize(W, method='tril') self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} diff --git a/pygsp/tests/test_utils.py b/pygsp/tests/test_utils.py index f44d066d..cbd824b1 100644 --- a/pygsp/tests/test_utils.py +++ b/pygsp/tests/test_utils.py @@ -23,6 +23,15 @@ def setUpClass(cls): def tearDownClass(cls): pass + def test_symmetrize(self): + W = sparse.random(100, 100, random_state=42) + for method in ['average', 'maximum', 'fill', 'tril', 'triu']: + # Test that the regular and sparse versions give the same result. + W1 = utils.symmetrize(W, method=method) + W2 = utils.symmetrize(W.toarray(), method=method) + 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)) diff --git a/pygsp/utils.py b/pygsp/utils.py index e51f8745..53856dc4 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -215,7 +215,7 @@ def resistance_distance(G): return rd -def symmetrize(W, symmetrize_type='average'): +def symmetrize(W, method='average'): r""" Symmetrize a square matrix. @@ -223,43 +223,85 @@ def symmetrize(W, symmetrize_type='average'): ---------- W : array_like Square matrix to be symmetrized - symmetrize_type : string - 'average' : symmetrize by averaging with the transpose. - 'full' : symmetrize by filling in the holes in the transpose. + method : string + * 'average' : symmetrize by averaging with the transpose. Most useful + when transforming a directed graph to an undirected one. + * 'maximum' : symmetrize by taking the maximum with the transpose. + Similar to 'fill' except that ambiguous entries are resolved by + taking the largest value. + * 'fill' : symmetrize by filling in the zeros in both the upper and + lower triangular parts. Ambiguous entries are resolved by averaging + the values. + * 'tril' : symmetrize by considering the lower triangular part only. + * 'triu' : symmetrize by considering the upper triangular part only. Examples -------- >>> from pygsp import utils - >>> x = np.array([[1,0],[3,4.]]) - >>> x - array([[ 1., 0.], - [ 3., 4.]]) - >>> utils.symmetrize(x) - array([[ 1. , 1.5], - [ 1.5, 4. ]]) - >>> utils.symmetrize(x, symmetrize_type='full') - array([[ 1., 3.], - [ 3., 4.]]) + >>> W = np.array([[0, 3, 0], [3, 1, 6], [4, 2, 3]], dtype=float) + >>> W + array([[ 0., 3., 0.], + [ 3., 1., 6.], + [ 4., 2., 3.]]) + >>> utils.symmetrize(W, method='average') + array([[ 0., 3., 2.], + [ 3., 1., 4.], + [ 2., 4., 3.]]) + >>> utils.symmetrize(W, method='maximum') + array([[ 0., 3., 4.], + [ 3., 1., 6.], + [ 4., 6., 3.]]) + >>> utils.symmetrize(W, method='fill') + array([[ 0., 3., 4.], + [ 3., 1., 4.], + [ 4., 4., 3.]]) + >>> utils.symmetrize(W, method='tril') + array([[ 0., 3., 4.], + [ 3., 1., 2.], + [ 4., 2., 3.]]) + >>> utils.symmetrize(W, method='triu') + array([[ 0., 3., 0.], + [ 3., 1., 6.], + [ 0., 6., 3.]]) """ if W.shape[0] != W.shape[1]: - raise ValueError("Matrix must be square") + raise ValueError('Matrix must be square.') + + if method == 'average': + return (W + W.T) / 2 + + # Sum is 2x average. It is not a good candidate as it modifies an already + # symmetric matrix. - sparse_flag = True if sparse.issparse(W) else False + elif method == 'maximum': + if sparse.issparse(W): + bigger = (W.T > W) + return W - W.multiply(bigger) + W.T.multiply(bigger) + else: + return np.maximum(W, W.T) - if symmetrize_type == 'average': - return (W + W.T) / 2. - elif symmetrize_type == 'full': - A = (W > 0) - if sparse_flag: - mask = ((A + A.T) - A).astype('float') + elif method == 'fill': + A = (W > 0) # Boolean type. + if sparse.issparse(W): + mask = (A + A.T) - A + W = W + mask.multiply(W.T) + else: + # Numpy boolean subtract is deprecated. + mask = np.logical_xor(np.logical_or(A, A.T), A) + W = W + mask * W.T + return symmetrize(W, method='average') # Resolve ambiguous entries. + + elif method in ['tril', 'triu']: + if sparse.issparse(W): + tri = getattr(sparse, method) else: - # numpy boolean subtract is deprecated in python 3 - mask = np.logical_xor(np.logical_or(A, A.T), A).astype('float') - W = W + (mask.multiply(W.T) if sparse_flag else (mask * W.T)) - return (W + W.T) / 2. # Resolve ambiguous entries + tri = getattr(np, method) + W = tri(W) + return symmetrize(W, method='maximum') + else: - raise ValueError("Unknown symmetrization type.") + raise ValueError('Unknown symmetrization method {}.'.format(method)) def rescale_center(x): From 2e865c22f734cf7979f6acd5aece53e38817b5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 17:58:25 +0200 Subject: [PATCH 300/392] RandomRegular: don't print anything if alright --- pygsp/graphs/randomregular.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index fc8689e0..a322ae9b 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -135,5 +135,3 @@ def is_regular(self): if warn: self.logger.warning('{}.'.format(msg[:-1])) - else: - self.logger.info('{} is ok.'.format(msg)) From 31d2278d88b9ef08432f31f023f9a2cfb453dc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 20 Sep 2017 17:59:22 +0200 Subject: [PATCH 301/392] graphs: eliminate edges of weight 0 --- pygsp/graphs/graph.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index bc25187b..de5c2d11 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -80,6 +80,11 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', if len(W.shape) != 2 or W.shape[0] != W.shape[1]: raise ValueError('W has incorrect shape {}'.format(W.shape)) + # Don't keep edges of 0 weight. Otherwise Ne will not correspond to the + # real number of edges. Problematic when e.g. plotting. + W = sparse.csr_matrix(W) + W.eliminate_zeros() + self.N = W.shape[0] self.W = sparse.lil_matrix(W) From e6f663b09269ecd35c3f9849783853f22792a2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 12:04:10 +0200 Subject: [PATCH 302/392] ErdosRenyi: implement as SBM with 1 block SBM has a more efficient implementation. Plus generalization. Improvements in SBM will affect both. SBM gained 'connected' option, and ErdosRenyi gained 'directed'. --- pygsp/graphs/erdosrenyi.py | 61 +++++----------- pygsp/graphs/stochasticblockmodel.py | 101 ++++++++++++++++----------- pygsp/tests/test_graphs.py | 22 +++--- 3 files changed, 95 insertions(+), 89 deletions(-) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 7e3126d8..35ae8b99 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- -import numpy as np -from scipy import sparse +# prevent circular import in Python < 3.5 +from .stochasticblockmodel import StochasticBlockModel -from . import Graph # prevent circular import in Python < 3.5 - -class ErdosRenyi(Graph): +class ErdosRenyi(StochasticBlockModel): r"""Erdos Renyi graph. The Erdos Renyi graph is constructed by randomly connecting nodes. Each @@ -19,12 +17,16 @@ class ErdosRenyi(Graph): Number of nodes (default is 100). p : float Probability to connect a node with another one. + directed : bool + Allow directed edges if True (default is False). + self_loops : bool + Allow self loops if True (default is False). connected : bool Force the graph to be connected (default is False). - directed : bool - Define if the graph is directed (default is False). max_iter : int - Maximum number of trials to connect the graph (default is 10). + Maximum number of trials to get a connected graph (default is 10). + seed : int + Seed for the random number generator (for reproducible graphs). Examples -------- @@ -32,38 +34,13 @@ class ErdosRenyi(Graph): """ - def __init__(self, N=100, p=0.1, connected=False, directed=False, - max_iter=10, **kwargs): - self.p = p - - if not 0 <= p <= 1: - raise ValueError('Probability p should be in [0, 1].') - - M = int(N * (N-1) if directed else N * (N-1) / 2) - nb_elem = int(p * M) - - nb_iter = 0 - while True: - indices = np.random.permutation(M)[:nb_elem] - - if directed: - all_ind = np.tril_indices(N, N-1) - non_diag = tuple(map(lambda dim: dim[condlist], all_ind)) - indices = tuple(map(lambda coord: coord[indices], non_diag)) - else: - indices = tuple(map(lambda coord: coord[indices], np.tril_indices(N, -1))) - - matrix = sparse.csr_matrix((np.ones(nb_elem), indices), shape=(N, N)) - self.W = matrix if directed else matrix + matrix.T - self.A = self.W > 0 - - if not connected or self.is_connected(recompute=True): - break - nb_iter += 1 - if nb_iter > max_iter: - raise ValueError('The graph could not be connected after {} ' - 'trials. Increase the connection probability ' - 'or the number of trials.'.format(max_iter)) + def __init__(self, N=100, p=0.1, directed=False, self_loops=False, + connected=False, max_iter=10, seed=None, **kwargs): - super(ErdosRenyi, self).__init__(W=self.W, gtype=u"Erdös Renyi", - **kwargs) + super(ErdosRenyi, self).__init__(N=N, k=1, p=p, + directed=directed, + self_loops=self_loops, + connected=connected, + max_iter=max_iter, + seed=seed) + self.gtype = u"Erdös Renyi" diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 8da8a0a7..118c98cd 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -19,26 +19,30 @@ class StochasticBlockModel(Graph): Parameters ---------- N : int - Number of nodes (default is 1024) + Number of nodes (default is 1024). k : float - Number of classes (default is 5) + Number of classes (default is 5). z : array_like the vector of length N containing the association between nodes and - classes. Default uniform. + classes (default is random uniform). M : array_like the k by k matrix containing the probability of connecting nodes based - on their class belonging. Default using p and q. + on their class belonging (default using p and q). p : float or array_like the diagonal value(s) for the matrix M. If scalar they all have the - same value. Otherwise expect a length k vector. Default p = 0.7. + same value. Otherwise expect a length k vector (default is p = 0.7). q : float or array_like the off-diagonal value(s) for the matrix M. If scalar they all have the same value. Otherwise expect a k x k matrix, diagonal will be - discarded. Default q = 0.3/k. - undirected : bool - force the graph to be undirected. Default True. - no_self_loop : bool - force the graph to have no self loop. Default True. + discarded (default is q = 0.3/k). + directed : bool + Allow directed edges if True (default is False). + self_loops : bool + Allow self loops if True (default is False). + connected : bool + Force the graph to be connected (default is False). + max_iter : int + Maximum number of trials to get a connected graph (default is 10). seed : int Seed for the random number generator (for reproducible graphs). @@ -49,7 +53,8 @@ class StochasticBlockModel(Graph): """ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, - undirected=True, no_self_loop=True, seed=None, **kwargs): + directed=False, self_loops=False, connected=False, + max_iter=10, seed=None, **kwargs): rs = np.random.RandomState(seed) @@ -57,22 +62,19 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, z = rs.randint(0, k, N) if M is None: - if isinstance(p, float): - p *= np.ones(k) - elif isinstance(p, list): - p = np.array(p) - if p.shape != (k, ): + p = np.asarray(p) + if p.size == 1: + p = p * np.ones(k) + if p.shape != (k,): raise ValueError('Optional parameter p is neither a scalar ' 'nor a vector of length k.') if q is None: q = 0.3 / k - if isinstance(q, float): - q *= np.ones((k, k)) - elif isinstance(q, list): - q = np.array(q) - + q = np.asarray(q) + if q.size == 1: + q = q * np.ones((k, k)) if q.shape != (k, k): raise ValueError('Optional parameter q is neither a scalar ' 'nor a matrix of size k x k.') @@ -80,25 +82,46 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, M = q M.flat[::k+1] = p # edit the diagonal terms - nb_row, nb_col = 0, 0 - csr_data, csr_i, csr_j = [], [], [] - for _ in range(N**2): - if nb_row != nb_col or not no_self_loop: - if nb_row >= nb_col or not undirected: - if rs.uniform() < M[z[nb_row], z[nb_col]]: - csr_data.append(1) - csr_i.append(nb_row) - csr_j.append(nb_col) - if nb_row < N-1: - nb_row += 1 + if (M < 0).any() or (M > 1).any(): + raise ValueError('Probabilities should be in [0, 1].') + + # TODO: higher memory, lesser computation alternative. + # 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(max_iter): + + nb_row, nb_col = 0, 0 + csr_data, csr_i, csr_j = [], [], [] + for _ in range(N**2): + if nb_row != nb_col or self_loops: + if nb_row >= nb_col or directed: + if rs.uniform() < M[z[nb_row], z[nb_col]]: + csr_data.append(1) + csr_i.append(nb_row) + csr_j.append(nb_col) + if nb_row < N-1: + nb_row += 1 + else: + nb_row = 0 + nb_col += 1 + + W = sparse.csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) + + if not directed: + W = utils.symmetrize(W, method='tril') + + if not connected: + break else: - nb_row = 0 - nb_col += 1 - - W = sparse.csr_matrix((csr_data, (csr_i, csr_j)), shape=(N, N)) - - if undirected: - W = utils.symmetrize(W, method='tril') + self.W = W + self.A = (W > 0) + if self.is_connected(recompute=True): + break + if nb_iter == max_iter - 1: + raise ValueError('The graph could not be connected after {} ' + 'trials. Increase the connection probability ' + 'or the number of trials.'.format(max_iter)) self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index eca7793d..531d6a92 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -256,10 +256,14 @@ def test_sensor(self): graphs.Sensor(connected=False) def test_stochasticblockmodel(self): - graphs.StochasticBlockModel(undirected=True) - graphs.StochasticBlockModel(undirected=False) - graphs.StochasticBlockModel(no_self_loop=True) - graphs.StochasticBlockModel(no_self_loop=False) + graphs.StochasticBlockModel(directed=True) + graphs.StochasticBlockModel(directed=False) + graphs.StochasticBlockModel(self_loops=True) + graphs.StochasticBlockModel(self_loops=False) + graphs.StochasticBlockModel(connected=True) + graphs.StochasticBlockModel(connected=False) + self.assertRaises(ValueError, graphs.StochasticBlockModel, + p=0, q=0, connected=True) def test_airfoil(self): graphs.Airfoil() @@ -270,10 +274,12 @@ def test_davidsensornet(self): graphs.DavidSensorNet(N=128) def test_erdosreny(self): - graphs.ErdosRenyi(connected=False) - graphs.ErdosRenyi(connected=True) - graphs.ErdosRenyi(directed=False) - # graphs.ErdosRenyi(directed=True) # TODO: bug in implementation + graphs.ErdosRenyi(connected=False, directed=False) + graphs.ErdosRenyi(connected=False, directed=True) + graphs.ErdosRenyi(connected=True, directed=False) + graphs.ErdosRenyi(connected=True, directed=True) + G = graphs.ErdosRenyi(N=100, p=1, self_loops=True) + self.assertEqual(G.W.nnz, 100**2) def test_fullconnected(self): graphs.FullConnected() From a7244477d287b7e4a59da2cd88195a37c69c6a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 12:07:35 +0200 Subject: [PATCH 303/392] speed up graph tests --- pygsp/tests/test_graphs.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 531d6a92..5dda8a36 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -46,12 +46,12 @@ def test_graph(self): def test_laplacian(self): # TODO: should test correctness. - G = graphs.StochasticBlockModel(undirected=True) + G = graphs.StochasticBlockModel(N=100, directed=False) self.assertFalse(G.is_directed()) G.compute_laplacian(lap_type='combinatorial') G.compute_laplacian(lap_type='normalized') - G = graphs.StochasticBlockModel(undirected=False) + G = graphs.StochasticBlockModel(N=100, directed=True) self.assertTrue(G.is_directed()) G.compute_laplacian(lap_type='combinatorial') self.assertRaises(NotImplementedError, G.compute_laplacian, @@ -134,19 +134,19 @@ def test_modulate(self): pass def test_edge_list(self): - G = graphs.StochasticBlockModel(undirected=True) + G = graphs.StochasticBlockModel(N=100, directed=False) v_in, v_out, weights = G.get_edge_list() self.assertEqual(G.W[v_in[42], v_out[42]], weights[42]) - G = graphs.StochasticBlockModel(undirected=False) + G = graphs.StochasticBlockModel(N=100, directed=True) self.assertRaises(NotImplementedError, G.get_edge_list) def test_differential_operator(self): - G = graphs.StochasticBlockModel(undirected=True) + G = graphs.StochasticBlockModel(N=100, directed=False) L = G.D.T.dot(G.D) np.testing.assert_allclose(L.toarray(), G.L.toarray()) - G = graphs.StochasticBlockModel(undirected=False) + G = graphs.StochasticBlockModel(N=100, directed=True) self.assertRaises(NotImplementedError, G.compute_differential_operator) def test_difference(self): @@ -158,7 +158,7 @@ def test_difference(self): def test_set_coordinates(self): G = graphs.FullConnected() - coords = np.random.uniform(size=(G.N, 2)) + coords = self._rs.uniform(size=(G.N, 2)) G.set_coordinates(coords) G.set_coordinates('ring2D') G.set_coordinates('random2D') @@ -216,7 +216,7 @@ def test_sphere(self): graphs.Sphere() def test_twomoons(self): - graphs.TwoMoons() + graphs.TwoMoons(moontype='standard') graphs.TwoMoons(moontype='synthesized') def test_torus(self): @@ -256,14 +256,14 @@ def test_sensor(self): graphs.Sensor(connected=False) def test_stochasticblockmodel(self): - graphs.StochasticBlockModel(directed=True) - graphs.StochasticBlockModel(directed=False) - graphs.StochasticBlockModel(self_loops=True) - graphs.StochasticBlockModel(self_loops=False) - graphs.StochasticBlockModel(connected=True) - graphs.StochasticBlockModel(connected=False) + graphs.StochasticBlockModel(N=100, directed=True) + 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) + graphs.StochasticBlockModel(N=100, connected=False) self.assertRaises(ValueError, graphs.StochasticBlockModel, - p=0, q=0, connected=True) + N=100, p=0, q=0, connected=True) def test_airfoil(self): graphs.Airfoil() @@ -274,10 +274,10 @@ def test_davidsensornet(self): graphs.DavidSensorNet(N=128) def test_erdosreny(self): - graphs.ErdosRenyi(connected=False, directed=False) - graphs.ErdosRenyi(connected=False, directed=True) - graphs.ErdosRenyi(connected=True, directed=False) - graphs.ErdosRenyi(connected=True, directed=True) + graphs.ErdosRenyi(N=100, connected=False, directed=False) + graphs.ErdosRenyi(N=100, connected=False, directed=True) + graphs.ErdosRenyi(N=100, connected=True, directed=False) + graphs.ErdosRenyi(N=100, connected=True, directed=True) G = graphs.ErdosRenyi(N=100, p=1, self_loops=True) self.assertEqual(G.W.nnz, 100**2) From d2cb8e644df3bc3c4e99453cb766455affffa6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 18:13:44 +0200 Subject: [PATCH 304/392] doc: show adjacency matrices and plot all graphs --- pygsp/graphs/airfoil.py | 6 ++++-- pygsp/graphs/barabasialbert.py | 7 ++++++- pygsp/graphs/comet.py | 7 +++++-- pygsp/graphs/community.py | 7 +++++-- pygsp/graphs/davidsensornet.py | 7 +++++-- pygsp/graphs/erdosrenyi.py | 7 ++++++- pygsp/graphs/fullconnected.py | 9 ++++++++- pygsp/graphs/grid2d.py | 7 +++++-- pygsp/graphs/logo.py | 7 +++++-- pygsp/graphs/lowstretchtree.py | 7 +++++-- pygsp/graphs/minnesota.py | 6 ++++-- pygsp/graphs/nngraphs/bunny.py | 9 ++++++--- pygsp/graphs/nngraphs/cube.py | 9 ++++++--- pygsp/graphs/nngraphs/grid2dimgpatches.py | 13 ++++++++----- pygsp/graphs/nngraphs/imgpatches.py | 9 +++++++-- pygsp/graphs/nngraphs/nngraph.py | 9 ++++++--- pygsp/graphs/nngraphs/sphere.py | 9 ++++++--- pygsp/graphs/nngraphs/twomoons.py | 9 ++++++--- pygsp/graphs/path.py | 7 +++++-- pygsp/graphs/randomregular.py | 7 ++++++- pygsp/graphs/randomring.py | 9 +++++++-- pygsp/graphs/ring.py | 7 +++++-- pygsp/graphs/sensor.py | 7 +++++-- pygsp/graphs/stochasticblockmodel.py | 9 ++++++++- pygsp/graphs/swissroll.py | 9 +++++++-- pygsp/graphs/torus.py | 10 ++++++++-- 26 files changed, 154 insertions(+), 55 deletions(-) diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 34af6072..d763a17a 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -13,8 +13,10 @@ class Airfoil(Graph): Examples -------- >>> import matplotlib.pyplot as plt - >>> fig, ax = plt.subplots(figsize=(7, 5)) - >>> graphs.Airfoil().plot(show_edges=True, ax=ax) + >>> G = graphs.Airfoil() + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.5) + >>> G.plot(show_edges=True, ax=axes[1]) """ diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index 944e7fcd..c907bad9 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -33,7 +33,12 @@ class BarabasiAlbert(Graph): Examples -------- - >>> G = graphs.BarabasiAlbert() + >>> import matplotlib.pyplot as plt + >>> G = graphs.BarabasiAlbert(N=150, seed=42) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ def __init__(self, N=1000, m0=1, m=1, seed=None, **kwargs): diff --git a/pygsp/graphs/comet.py b/pygsp/graphs/comet.py index 623e7359..7cd47d6f 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -20,8 +20,11 @@ class Comet(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Comet(20, 10).plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Comet(15, 10) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index aa407064..8727bffa 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -51,8 +51,11 @@ class Community(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Community().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Community(N=250, Nc=3, comm_sizes=[50, 120, 80], seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.5) + >>> G.plot(ax=axes[1]) """ def __init__(self, diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 7fc2c56d..354513dc 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -20,8 +20,11 @@ class DavidSensorNet(Graph): Examples -------- - >>> import matplotlib - >>> graphs.DavidSensorNet().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.DavidSensorNet() + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 35ae8b99..eca37a29 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -30,7 +30,12 @@ class ErdosRenyi(StochasticBlockModel): Examples -------- - >>> G = graphs.ErdosRenyi() + >>> import matplotlib.pyplot as plt + >>> G = graphs.ErdosRenyi(N=64, seed=42) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index a938795e..00d6f414 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -8,6 +8,8 @@ class FullConnected(Graph): r"""Fully connected graph. + All weights are set to 1. There is no self-connections. + Parameters ---------- N : int @@ -15,7 +17,12 @@ class FullConnected(Graph): Examples -------- - >>> G = graphs.FullConnected() + >>> import matplotlib.pyplot as plt + >>> G = graphs.FullConnected(N=20) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=5) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index deff5e82..681e9916 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -19,8 +19,11 @@ class Grid2d(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Grid2d().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Grid2d(N1=5, N2=4) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 6bd99a54..384fb6d0 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -11,8 +11,11 @@ class Logo(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Logo().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Logo() + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.5) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index 988fb394..8de7f385 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -20,8 +20,11 @@ class LowStretchTree(Graph): Examples -------- - >>> import matplotlib - >>> graphs.LowStretchTree(k=3).plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.LowStretchTree(k=2) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 8f875b05..6f471a8b 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -24,8 +24,10 @@ class Minnesota(Graph): Examples -------- >>> import matplotlib.pyplot as plt - >>> fig, ax = plt.subplots(figsize=(7, 5)) - >>> graphs.Minnesota().plot(ax=ax) + >>> G = graphs.Minnesota() + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.5) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index 7c537f87..e1234f86 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -14,9 +14,12 @@ class Bunny(NNGraph): Examples -------- >>> import matplotlib.pyplot as plt - >>> fig = plt.figure(figsize=(10, 8)) - >>> ax = fig.add_subplot(111, projection='3d') - >>> graphs.Bunny().plot(ax=ax) + >>> G = graphs.Bunny() + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=0.1) + >>> G.plot(ax=ax2) """ diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index 714176ba..fe66992f 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -25,9 +25,12 @@ class Cube(NNGraph): Examples -------- >>> import matplotlib.pyplot as plt - >>> fig = plt.figure(figsize=(10, 8)) - >>> ax = fig.add_subplot(111, projection='3d') - >>> graphs.Cube(seed=42).plot(ax=ax) + >>> G = graphs.Cube(seed=42) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=0.5) + >>> G.plot(ax=ax2) """ diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index e5951709..2ab5a258 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -24,17 +24,20 @@ class Grid2dImgPatches(Graph): Examples -------- - >>> import matplotlib + >>> import matplotlib.pyplot as plt >>> from skimage import data, img_as_float - >>> img = img_as_float(data.camera()[::32, ::32]) - >>> graphs.Grid2dImgPatches(img).plot() + >>> img = img_as_float(data.camera()[::64, ::64]) + >>> G = graphs.Grid2dImgPatches(img, use_flann=False) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): - Gg = Grid2d(img.shape[0], img.shape[1]) - Gp = ImgPatches(img=img, patch_shape=patch_shape, n_nbrs=n_nbrs) + Gg = Grid2d(img.shape[0], img.shape[1], **kwargs) + Gp = ImgPatches(img, patch_shape=patch_shape, n_nbrs=n_nbrs, **kwargs) gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), gtype=gtype, diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index f80c4b77..8b45305c 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -22,9 +22,14 @@ class ImgPatches(NNGraph): Examples -------- + >>> import matplotlib.pyplot as plt >>> from skimage import data, img_as_float - >>> img = img_as_float(data.camera()[::2, ::2]) - >>> G = graphs.ImgPatches(img) + >>> img = img_as_float(data.camera()[::64, ::64]) + >>> G = graphs.ImgPatches(img, use_flann=False) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 7bf20e00..8e4b6f44 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -63,9 +63,12 @@ class NNGraph(Graph): Examples -------- - >>> import matplotlib - >>> X = np.random.uniform(size=(30, 2)) - >>> graphs.NNGraph(X).plot() + >>> import matplotlib.pyplot as plt + >>> X = np.random.RandomState(42).uniform(size=(30, 2)) + >>> G = graphs.NNGraph(X) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=5) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 3e9dc232..111a5b41 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -25,9 +25,12 @@ class Sphere(NNGraph): Examples -------- >>> import matplotlib.pyplot as plt - >>> fig = plt.figure(figsize=(10, 8)) - >>> ax = fig.add_subplot(111, projection='3d') - >>> graphs.Sphere(seed=42).plot(ax=ax) + >>> G = graphs.Sphere(nb_pts=100, seed=42) + >>> 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) """ diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index a2da5fc5..9dfac1f8 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -36,8 +36,11 @@ class TwoMoons(NNGraph): Examples -------- - >>> import matplotlib - >>> graphs.TwoMoons().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.TwoMoons() + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.5) + >>> G.plot(show_edges=True, ax=axes[1]) """ @@ -86,7 +89,7 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, self.labels = np.concatenate((np.zeros(N1), np.ones(N2))) plotting = { - 'vertex_size': 5, + 'vertex_size': 30, } super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index 001b222a..e18f00a0 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -16,8 +16,11 @@ class Path(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Path().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Path(N=10) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) References ---------- diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index a322ae9b..43a6fe61 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -38,7 +38,12 @@ class RandomRegular(Graph): Examples -------- - >>> G = graphs.RandomRegular() + >>> import matplotlib.pyplot as plt + >>> G = graphs.RandomRegular(N=64, k=5, seed=42) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 65a4bd06..2f217e2f 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -19,8 +19,13 @@ class RandomRing(Graph): Examples -------- - >>> import matplotlib - >>> graphs.RandomRing().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.RandomRing(N=10, seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) + >>> _ = axes[1].set_xlim(-1.1, 1.1) + >>> _ = axes[1].set_ylim(-1.1, 1.1) """ diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index 372e700a..e28fbed3 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -18,8 +18,11 @@ class Ring(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Ring().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Ring(N=10) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 94a668d7..9dcf8b0f 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -29,8 +29,11 @@ class Sensor(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Sensor().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Sensor(N=64, seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=2) + >>> G.plot(ax=axes[1]) """ diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 118c98cd..6303c43a 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -48,7 +48,13 @@ class StochasticBlockModel(Graph): Examples -------- - >>> G = graphs.StochasticBlockModel() + >>> import matplotlib.pyplot as plt + >>> G = graphs.StochasticBlockModel( + ... 100, k=3, p=[0.4, 0.6, 0.3], q=0.02, seed=42) + >>> G.set_coordinates(kind='spring', seed=42) + >>> fig, axes = plt.subplots(1, 2) + >>> _ = axes[0].spy(G.W, markersize=0.8) + >>> G.plot(ax=axes[1]) """ @@ -60,6 +66,7 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, if z is None: z = rs.randint(0, k, N) + z.sort() # Sort for nice spy plot of W, where blocks are apparent. if M is None: diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index e281ccec..ba0afce5 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -33,8 +33,13 @@ class SwissRoll(Graph): Examples -------- - >>> import matplotlib - >>> graphs.SwissRoll(seed=42).plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.SwissRoll(N=200, seed=42) + >>> fig = plt.figure() + >>> ax1 = fig.add_subplot(121) + >>> ax2 = fig.add_subplot(122, projection='3d') + >>> _ = ax1.spy(G.W, markersize=1) + >>> G.plot(ax=ax2) """ diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 26665813..87bcac45 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -22,8 +22,14 @@ class Torus(Graph): Examples -------- - >>> import matplotlib - >>> graphs.Torus().plot() + >>> import matplotlib.pyplot as plt + >>> G = graphs.Torus(10) + >>> 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) + >>> _ = ax2.set_zlim(-1.5, 1.5) """ From 236601f612fc746ea61f073c9b507c139c87dcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 18:16:23 +0200 Subject: [PATCH 305/392] plotting: graph name, number of nodes and edges as plot titles --- pygsp/plotting.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 04b5b0de..69597ab8 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -75,9 +75,12 @@ def inner(obj, *args, **kwargs): kwargs.update(ax=ax) save_as = kwargs.pop('save_as', None) + plot_name = kwargs.pop('plot_name', '') plot(obj, *args, **kwargs) + kwargs['ax'].set_title(plot_name) + try: if save_as is not None: fig.savefig(save_as + '.png') @@ -206,7 +209,8 @@ def plot_graph(G, backend=None, **kwargs): default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - kwargs['plot_name'] = kwargs.pop('plot_name', G.gtype) + plot_name = '{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) if backend is None: backend = BACKEND @@ -222,7 +226,7 @@ def plot_graph(G, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_graph(G, show_edges, vertex_size, plot_name, ax): +def _plt_plot_graph(G, show_edges, vertex_size, ax): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph @@ -330,8 +334,7 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): @_plt_handle_figure def plot_filter(filters, npoints=1000, line_width=4, x_width=3, - x_size=10, plot_eigenvalues=None, show_sum=None, - plot_name=None, ax=None): + x_size=10, plot_eigenvalues=None, show_sum=None, ax=None): r""" Plot the spectral response of a filter bank, a set of graph filters. @@ -471,7 +474,8 @@ def plot_signal(G, signal, backend=None, **kwargs): default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - kwargs['plot_name'] = kwargs.pop('plot_name', G.gtype) + plot_name = '{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) limits = [1.05*signal.min(), 1.05*signal.max()] kwargs['limits'] = kwargs.pop('limits', limits) @@ -490,7 +494,7 @@ def plot_signal(G, signal, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_signal(G, signal, show_edges, limits, plot_name, ax, +def _plt_plot_signal(G, signal, show_edges, limits, ax, vertex_size, colorbar=True): if show_edges: From d881b901a58acdff103f3398518b5260bad4a5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 18:51:38 +0200 Subject: [PATCH 306/392] graphs: all models should pass kwargs to base class Useful to e.g. set the type of Laplacian at construction. --- pygsp/graphs/davidsensornet.py | 5 +++-- pygsp/graphs/erdosrenyi.py | 3 ++- pygsp/graphs/fullconnected.py | 4 ++-- pygsp/graphs/minnesota.py | 6 +++--- pygsp/graphs/nngraphs/twomoons.py | 5 +++-- pygsp/graphs/swissroll.py | 5 +++-- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 354513dc..5ab40ebb 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -28,7 +28,7 @@ class DavidSensorNet(Graph): """ - def __init__(self, N=64, seed=None): + def __init__(self, N=64, seed=None, **kwargs): if N == 64: data = utils.loadmat('pointclouds/david64') assert data['N'][0, 0] == N @@ -55,4 +55,5 @@ def __init__(self, N=64, seed=None): plotting = {"limits": [0, 1, 0, 1]} super(DavidSensorNet, self).__init__(W=W, gtype='davidsensornet', - coords=coords, plotting=plotting) + coords=coords, plotting=plotting, + **kwargs) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index eca37a29..40e016d9 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -47,5 +47,6 @@ def __init__(self, N=100, p=0.1, directed=False, self_loops=False, self_loops=self_loops, connected=connected, max_iter=max_iter, - seed=seed) + seed=seed, + **kwargs) self.gtype = u"Erdös Renyi" diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index 00d6f414..9e89a05f 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -26,10 +26,10 @@ class FullConnected(Graph): """ - def __init__(self, N=10): + 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, gtype='full', - plotting=plotting) + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 6f471a8b..343ef6de 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -31,7 +31,7 @@ class Minnesota(Graph): """ - def __init__(self, connect=True): + def __init__(self, connect=True, **kwargs): data = utils.loadmat('pointclouds/minnesota') self.labels = data['labels'] @@ -57,5 +57,5 @@ def __init__(self, connect=True): gtype = 'minnesota-disconnected' - super(Minnesota, self).__init__(W=A, coords=data['xy'], - gtype=gtype, plotting=plotting) + super(Minnesota, self).__init__(W=A, coords=data['xy'], gtype=gtype, + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 9dfac1f8..f6b7b558 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -64,7 +64,7 @@ def _create_arc_moon(self, N, sigmad, d, number, seed): return np.concatenate((moonx, moony), axis=1) def __init__(self, moontype='standard', dim=2, sigmag=0.05, - N=400, sigmad=0.07, d=0.5, seed=None): + N=400, sigmad=0.07, d=0.5, seed=None, **kwargs): if moontype == 'standard': gtype = 'Two Moons standard' @@ -93,4 +93,5 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, } super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, - plotting=plotting, gtype=gtype) + plotting=plotting, gtype=gtype, + **kwargs) diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index ba0afce5..260cabe9 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -44,7 +44,7 @@ class SwissRoll(Graph): """ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, - noise=False, srtype='uniform', seed=None): + noise=False, srtype='uniform', seed=None, **kwargs): if s is None: s = np.sqrt(2. / N) @@ -86,4 +86,5 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, gtype = 'swiss roll {}'.format(srtype) super(SwissRoll, self).__init__(W=W, coords=coords.T, - plotting=plotting, gtype=gtype) + plotting=plotting, gtype=gtype, + **kwargs) From 359d5c9075b5a55d3f1a411a5deaa97d574583a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 19:31:48 +0200 Subject: [PATCH 307/392] plotting: handle unicode characters for Python 2 --- pygsp/plotting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 69597ab8..251cb79a 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -209,7 +209,7 @@ def plot_graph(G, backend=None, **kwargs): default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - plot_name = '{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + plot_name = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) if backend is None: @@ -474,7 +474,7 @@ def plot_signal(G, signal, backend=None, **kwargs): default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - plot_name = '{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + plot_name = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) limits = [1.05*signal.min(), 1.05*signal.max()] From 4a11b963189312c0f9585403f4012e42be66849f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 21 Sep 2017 19:52:38 +0200 Subject: [PATCH 308/392] plotting: no grey surrounding nodes when plotting graphs --- pygsp/plotting.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 251cb79a..876cf76e 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -244,7 +244,8 @@ def _plt_plot_graph(G, show_edges, vertex_size, ax): color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], marker='o', markersize=vertex_size/10, - markerfacecolor=G.plotting['vertex_color']) + markerfacecolor=G.plotting['vertex_color'], + markeredgecolor=G.plotting['vertex_color']) if G.coords.shape[1] == 3: # TODO: very dirty. Cannot we prepare a set of lines? @@ -255,7 +256,8 @@ def _plt_plot_graph(G, show_edges, vertex_size, ax): color=G.plotting['edge_color'], linestyle=G.plotting['edge_style'], marker='o', markersize=vertex_size/10, - markerfacecolor=G.plotting['vertex_color']) + markerfacecolor=G.plotting['vertex_color'], + markeredgecolor=G.plotting['vertex_color']) else: From 70efcf6785421b91892abcde941f36f47ddf07c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Sep 2017 17:26:19 +0200 Subject: [PATCH 309/392] plotting: do not fix ylim Because some filters have max smaller than 1, e.g. 0.4 for Mexican hat. --- pygsp/plotting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 876cf76e..57ff85a9 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -397,7 +397,6 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, if show_sum: ax.plot(x, np.sum(y**2, 1), 'k', linewidth=line_width) - ax.set_ylim(-0.1, 1.1) ax.set_xlabel("$\lambda$: laplacian's eigenvalues / graph frequencies") ax.set_ylabel('$\hat{g}(\lambda)$: filter response') From 202ed3b42dcddb6561e474b30cc8e7fcedea1b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 25 Sep 2017 17:44:36 +0200 Subject: [PATCH 310/392] plotting: highlight vertices, e.g. to show filter localization --- README.rst | 5 +++-- pygsp/data/readme_example.png | Bin 200321 -> 224744 bytes pygsp/filters/filter.py | 5 +++-- pygsp/plotting.py | 26 +++++++++++++++++++++----- pygsp/tests/test_plotting.py | 17 +++++++++++++++++ 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index bc283cc7..a0618384 100644 --- a/README.rst +++ b/README.rst @@ -60,10 +60,11 @@ with the above defined filter. Note how the diffusion follows the local structure! >>> import numpy as np +>>> DELTAS = [20, 30, 1090] >>> s = np.zeros(G.N) ->>> s[[20, 30, 1090]] = 1 +>>> s[DELTAS] = 1 >>> s = g.filter(s) ->>> G.plot_signal(s, backend='matplotlib') +>>> G.plot_signal(s, highlight=DELTAS, backend='matplotlib') .. image:: ../pygsp/data/readme_example.png :alt: diff --git a/pygsp/data/readme_example.png b/pygsp/data/readme_example.png index 21b160595e8898cc85c214c7eba6d7f595da9584..9c73d4bbcfc067686d80ff0dbdc09a080460b545 100644 GIT binary patch literal 224744 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 literal 200321 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%0A=;@is9A%PC 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 Oxv6|hsZhZp=>Gr$mR|1w diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 6101d17a..eba141de 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -296,11 +296,12 @@ def localize(self, i, **kwargs): >>> import matplotlib >>> N = 20 + >>> DELTA = N//2 * (N+1) >>> G = graphs.Grid2d(N) >>> G.estimate_lmax() >>> g = filters.Heat(G, 100) - >>> s = g.localize(N//2 * (N+1)) - >>> G.plot_signal(s) + >>> s = g.localize(DELTA) + >>> G.plot_signal(s, highlight=DELTA) """ s = np.zeros(self.G.N) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 57ff85a9..1baddb4d 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -419,8 +419,10 @@ def plot_signal(G, signal, backend=None, **kwargs): NOT IMPLEMENTED. Camera position when plotting a 3D graph. vertex_size : float Size of circle representing each node. - vertex_highlight : list of boolean - NOT IMPLEMENTED. Vector of indices for vertices to be highlighted. + highlight : iterable + List of indices of vertices to be highlighted. + Useful to e.g. show where a filter was localized. + Only available with the matplotlib backend. colorbar : bool Whether to plot a colorbar indicating the signal's amplitude. Only available with the matplotlib backend. @@ -429,8 +431,8 @@ def plot_signal(G, signal, backend=None, **kwargs): Defaults to signal minimum and maximum value. Only available with the matplotlib backend. bar : boolean - NOT IMPLEMENTED: False display color, True display bar for the graph - (default False). + NOT IMPLEMENTED. Signal values are displayed using colors when False, + and bars when True (default False). bar_width : int NOT IMPLEMENTED. Width of the bar (default 1). backend: {'matplotlib', 'pyqtgraph'} @@ -496,7 +498,7 @@ def plot_signal(G, signal, backend=None, **kwargs): @_plt_handle_figure def _plt_plot_signal(G, signal, show_edges, limits, ax, - vertex_size, colorbar=True): + vertex_size, highlight=[], colorbar=True): if show_edges: @@ -525,19 +527,33 @@ def _plt_plot_signal(G, signal, show_edges, limits, ax, linestyle=G.plotting['edge_style'], zorder=1) + try: + iter(highlight) + except TypeError: + highlight = [highlight] + coords_hl = G.coords[highlight] + if G.coords.ndim == 1: ax.plot(G.coords, signal) ax.set_ylim(limits) + for coord_hl in coords_hl: + ax.axvline(x=coord_hl, color='C1', linewidth=2) elif G.coords.shape[1] == 2: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], s=vertex_size, c=signal, zorder=2, vmin=limits[0], vmax=limits[1]) + ax.scatter(coords_hl[:, 0], coords_hl[:, 1], + s=2*vertex_size, zorder=3, + marker='o', c='None', edgecolors='C1', linewidths=2) elif G.coords.shape[1] == 3: sc = ax.scatter(G.coords[:, 0], G.coords[:, 1], G.coords[:, 2], s=vertex_size, c=signal, zorder=2, vmin=limits[0], vmax=limits[1]) + ax.scatter(coords_hl[:, 0], coords_hl[:, 1], coords_hl[:, 2], + s=2*vertex_size, zorder=3, + marker='o', c='None', edgecolors='C1', linewidths=2) try: ax.view_init(elev=G.plotting['elevation'], azim=G.plotting['azimuth']) diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index ef83f3c9..6706b75d 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -89,5 +89,22 @@ def test_save(self): os.remove(name + '.png') os.remove(name + '.pdf') + def test_highlight(self): + + def test(G): + s = np.arange(G.N) + G.plot_signal(s, backend='matplotlib', highlight=0) + G.plot_signal(s, backend='matplotlib', highlight=[0]) + G.plot_signal(s, backend='matplotlib', highlight=[0, 1]) + + # Test for 1, 2, and 3D graphs. + G = graphs.Ring() + test(G) + G = graphs.Ring() + G.set_coordinates('line1D') + test(G) + G = graphs.Torus(Nv=5) + test(G) + suite = unittest.TestLoader().loadTestsFromTestCase(TestCase) From 5d46e18418eaefe0fb1528a8d5bc48420107ac2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 08:07:08 +0200 Subject: [PATCH 311/392] Fourier transforms do not change signal shape --- pygsp/graphs/fourier.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pygsp/graphs/fourier.py b/pygsp/graphs/fourier.py index d646eb45..17825677 100644 --- a/pygsp/graphs/fourier.py +++ b/pygsp/graphs/fourier.py @@ -148,7 +148,9 @@ def gft(self, s): True """ - s = self.sanitize_signal(s) + if s.shape[0] != self.N: + raise ValueError('First dimension should be the number of nodes ' + 'G.N = {}, got {}.'.format(self.N, s.shape)) U = np.conjugate(self.U) # True Hermitian. (Although U is often real.) return np.tensordot(U, s, ([0], [0])) @@ -183,7 +185,9 @@ def igft(self, s_hat): True """ - s_hat = self.sanitize_signal(s_hat) + 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)) return np.tensordot(self.U, s_hat, ([1], [0])) def translate(self, f, i): From 31c7f1d222a1f13b9b2b79faa88a86e0f15c0d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 10:03:14 +0200 Subject: [PATCH 312/392] squeeze filtered signals Easier to play with interactively, especially when looking at one signal or filter. --- pygsp/filters/filter.py | 62 ++++++++++++++++++++++++------------- pygsp/reduction.py | 2 ++ pygsp/tests/test_filters.py | 45 ++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 30 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index eba141de..4316fa38 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -189,17 +189,17 @@ def filter(self, s, method='chebyshev', order=30): >>> s1[13] = 1 >>> s1 = filters.Heat(G, 3).filter(s1) >>> s1.shape - (30, 1, 1) + (30,) Filter and reconstruct our signal: >>> g = filters.MexicanHat(G, Nf=4) >>> s2 = g.filter(s1) >>> s2.shape - (30, 1, 4) + (30, 4) >>> s2 = g.filter(s2) >>> s2.shape - (30, 1, 1) + (30,) Look how well we were able to reconstruct: @@ -218,23 +218,39 @@ def filter(self, s, method='chebyshev', order=30): True """ - s = self.G.sanitize_signal(s) - N_NODES, N_SIGNALS, N_FEATURES_IN = s.shape + 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)) + + # TODO: not in self.Nin (Nf = Nin x Nout). + if s.ndim == 1 or s.shape[-1] not in [1, self.Nf]: + if s.ndim == 3: + raise ValueError('Third dimension (#features) should be ' + 'either 1 or the number of filters Nf = {}, ' + 'got {}.'.format(self.Nf, s.shape)) + s = np.expand_dims(s, -1) + n_features_in = s.shape[-1] + + if s.ndim < 3: + s = np.expand_dims(s, 1) + n_signals = s.shape[1] + + if s.ndim > 3: + raise ValueError('At most 3 dimensions: ' + '#nodes x #signals x #features.') + assert s.ndim == 3 # TODO: generalize to 2D (m --> n) filter banks. # Only 1 --> Nf (analysis) and Nf --> 1 (synthesis) for now. - if N_FEATURES_IN not in [1, self.Nf]: - raise ValueError('Last dimension (N_FEATURES) should either be ' - '1 or the number of filters (Nf), ' - 'not {}.'.format(s.shape)) - N_FEATURES_OUT = self.Nf if N_FEATURES_IN == 1 else 1 + n_features_out = self.Nf if n_features_in == 1 else 1 if method == 'exact': - axis = 1 if N_FEATURES_IN == 1 else 2 + # TODO: will be handled by g.adjoint(). + axis = 1 if n_features_in == 1 else 2 f = self.evaluate(self.G.e) f = np.expand_dims(f.T, axis) - assert f.shape == (N_NODES, N_FEATURES_IN, N_FEATURES_OUT) + assert f.shape == (self.G.N, n_features_in, n_features_out) s = self.G.gft(s) s = np.matmul(s, f) @@ -245,27 +261,29 @@ def filter(self, s, method='chebyshev', order=30): # TODO: update Chebyshev implementation (after 2D filter banks). c = approximations.compute_cheby_coeff(self, m=order) - if N_FEATURES_IN == 1: # Analysis. + if n_features_in == 1: # Analysis. s = s.squeeze(axis=2) s = approximations.cheby_op(self.G, c, s) - s = s.reshape((N_NODES, N_FEATURES_OUT, N_SIGNALS), order='F') + s = s.reshape((self.G.N, n_features_out, n_signals), order='F') s = s.swapaxes(1, 2) - elif N_FEATURES_IN == self.Nf: # Synthesis. + elif n_features_in == self.Nf: # Synthesis. s = s.swapaxes(1, 2) - s_in = s.reshape((N_NODES*N_FEATURES_IN, N_SIGNALS), order='F') - s = np.zeros((N_NODES, N_SIGNALS)) - tmpN = np.arange(N_NODES, dtype=int) - for i in range(N_FEATURES_IN): + s_in = s.reshape( + (self.G.N * n_features_in, n_signals), order='F') + s = np.zeros((self.G.N, n_signals)) + tmpN = np.arange(self.G.N, dtype=int) + for i in range(n_features_in): s += approximations.cheby_op(self.G, c[i], - s_in[i * N_NODES + tmpN]) + s_in[i * self.G.N + tmpN]) s = np.expand_dims(s, 2) else: raise ValueError('Unknown method {}.'.format(method)) - return s + # Return a 1D signal if e.g. a 1D signal was filtered by one filter. + return s.squeeze() def localize(self, i, **kwargs): r"""Localize the kernels at a node (to visualize them). @@ -428,7 +446,7 @@ def compute_frame(self, **kwargs): (1000, 1000, 6) >>> frame = frame.reshape(G.N, -1).T >>> s1 = np.dot(frame, s) - >>> s1 = s1.reshape(G.N, 1, -1) + >>> s1 = s1.reshape(G.N, -1) >>> >>> s2 = f.filter(s) >>> np.all((s1 - s2) < 1e-10) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index b28c046a..89388bfb 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -29,6 +29,8 @@ def _analysis(g, s, **kwargs): # TODO: that is the legacy analysis method. s = g.filter(s, **kwargs) + while s.ndim < 3: + s = np.expand_dims(s, 1) return s.swapaxes(1, 2).reshape(-1, s.shape[1], order='F') diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 903fe20f..5830faed 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -18,8 +18,8 @@ class TestCase(unittest.TestCase): def setUpClass(cls): cls._G = graphs.Logo() cls._G.compute_fourier_basis() - rs = np.random.RandomState(42) - cls._signal = rs.uniform(size=cls._G.N) + cls._rs = np.random.RandomState(42) + cls._signal = cls._rs.uniform(size=cls._G.N) @classmethod def tearDownClass(cls): @@ -59,8 +59,8 @@ def _test_methods(self, f, tight): if tight: # Tight frames should not loose information. - np.testing.assert_allclose(s4.squeeze(), A * self._signal) - assert np.linalg.norm(s5.squeeze() - A * self._signal) < 0.1 + np.testing.assert_allclose(s4, A * self._signal) + assert np.linalg.norm(s5 - A * self._signal) < 0.1 self.assertRaises(ValueError, f.filter, s2, method='lanczos') @@ -69,16 +69,45 @@ def _test_methods(self, f, tight): # Though it is memory intensive. F = f.compute_frame(method='exact') F = F.reshape(self._G.N, -1) - s = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + s = F.T.dot(self._signal).reshape(self._G.N, -1).squeeze() np.testing.assert_allclose(s, s2) F = f.compute_frame(method='chebyshev', order=100) F = F.reshape(self._G.N, -1) - s = F.T.dot(self._signal).reshape(self._G.N, 1, -1) + s = F.T.dot(self._signal).reshape(self._G.N, -1).squeeze() np.testing.assert_allclose(s, s3) # TODO: f.can_dual() + def test_filter(self): + Nf = 5 + g = filters.MexicanHat(self._G, Nf=Nf) + + s1 = self._rs.uniform(size=self._G.N) + s2 = s1.reshape((self._G.N, 1)) + s3 = g.filter(s1) + s4 = g.filter(s2) + assert s3.shape == (self._G.N, Nf) + np.testing.assert_allclose(s3, s4) + + s1 = self._rs.uniform(size=(self._G.N, 4)) + s2 = s1.reshape((self._G.N, 4, 1)) + s3 = g.filter(s1) + s4 = g.filter(s2) + assert s3.shape == (self._G.N, 4, Nf) + np.testing.assert_allclose(s3, s4) + + s1 = self._rs.uniform(size=(self._G.N, Nf)) + s2 = s1.reshape((self._G.N, 1, Nf)) + s3 = g.filter(s1) + s4 = g.filter(s2) + assert s3.shape == (self._G.N,) + np.testing.assert_allclose(s3, s4) + + s1 = self._rs.uniform(size=(self._G.N, 10, Nf)) + s3 = g.filter(s1) + assert s3.shape == (self._G.N, 10) + def test_localize(self): G = graphs.Grid2d(20) G.compute_fourier_basis() @@ -91,11 +120,11 @@ def test_localize(self): # Should be equal to a row / column of the filtering operator. gL = G.U.dot(np.diag(g.evaluate(G.e)[0]).dot(G.U.T)) s2 = np.sqrt(G.N) * gL[NODE, :] - np.testing.assert_allclose(s1.squeeze(), s2) + np.testing.assert_allclose(s1, s2) # That is actually a row / column of the analysis operator. F = g.compute_frame(method='exact') - np.testing.assert_allclose(F.squeeze(), gL) + np.testing.assert_allclose(F, gL) def test_custom_filter(self): def kernel(x): From 95b1b96d9d3286c48d4591abc6ad4f0ce3b1a4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 10:06:29 +0200 Subject: [PATCH 313/392] remove unused sanitize_signal --- pygsp/graphs/graph.py | 51 -------------------------------------- pygsp/tests/test_graphs.py | 17 ------------- 2 files changed, 68 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index de5c2d11..74d4fca8 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -718,57 +718,6 @@ def get_edge_list(self): assert self.Ne == v_in.size == v_out.size == weights.size return v_in, v_out, weights - def sanitize_signal(self, s): - r"""Standardize signal shape. - - Add singleton dimensions at the end and check the resulting shape. - - Parameters - ---------- - s : ndarray - Signal tensor of shape ``(N_NODES)``, ``(N_NODES, N_SIGNALS)``, or - ``(N_NODES, N_SIGNALS, N_FEATURES)``. - - Returns - ------- - s : ndarray - Signal tensor of shape ``(N_NODES, N_SIGNALS, N_FEATURES)``. - - Raises - ------ - ValueError - If the passed signal tensor is more than 3 dimensions or if the - first dimension's size is not the number of nodes. - - Examples - -------- - >>> G = graphs.Logo() - >>> s = np.ones(G.N) # One signal, one feature. - >>> G.sanitize_signal(s).shape - (1130, 1, 1) - >>> s = np.ones((G.N, 10)) # Ten signals of one feature. - >>> G.sanitize_signal(s).shape - (1130, 10, 1) - >>> s = np.ones((G.N, 10, 5)) # Ten signals of 5 features. - >>> G.sanitize_signal(s).shape - (1130, 10, 5) - - """ - if s.ndim == 1: - # Single signal, single feature. - s = np.expand_dims(s, axis=1) - - if s.ndim == 2: - # Multiple signals, single feature. - s = np.expand_dims(s, axis=2) - - if s.ndim != 3 or s.shape[0] != self.N: - raise ValueError('Signal must have shape N_NODES x N_SIGNALS x ' - 'N_FEATURES, not {}. Last singleton dimensions ' - 'may be omitted.'.format(s.shape)) - - return s - def modulate(self, f, k): r"""Modulate the signal *f* to the frequency *k*. diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 5dda8a36..0a3d8fef 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -171,23 +171,6 @@ def test_set_coordinates(self): G.set_coordinates('community2D') self.assertRaises(ValueError, G.set_coordinates, 'invalid') - def test_sanitize_signal(self): - s1 = np.arange(self._G.N) - s2 = np.reshape(s1, (self._G.N, 1)) - s3 = np.reshape(s1, (self._G.N, 1, 1)) - s4 = np.arange(self._G.N*10).reshape((self._G.N, 10)) - s5 = np.reshape(s4, (self._G.N, 10, 1)) - s1 = self._G.sanitize_signal(s1) - s2 = self._G.sanitize_signal(s2) - s3 = self._G.sanitize_signal(s3) - s4 = self._G.sanitize_signal(s4) - s5 = self._G.sanitize_signal(s5) - np.testing.assert_equal(s2, s1) - np.testing.assert_equal(s3, s1) - np.testing.assert_equal(s5, s4) - self.assertRaises(ValueError, self._G.sanitize_signal, - np.ones((2, 2, 2, 2))) - def test_nngraph(self): Xin = np.arange(90).reshape(30, 3) dist_types = ['euclidean', 'manhattan', 'max_dist', 'minkowski'] From b4ff3da7ec6d14bc3eeb872a6d8786cfb1f0b2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 10:33:34 +0200 Subject: [PATCH 314/392] filters: reintroduce analyze and synthesize as wrappers to filter --- pygsp/filters/__init__.py | 2 ++ pygsp/filters/filter.py | 26 ++++++++++++++++++++++---- pygsp/tests/test_filters.py | 8 ++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pygsp/filters/__init__.py b/pygsp/filters/__init__.py index 400a950b..aaa330e6 100644 --- a/pygsp/filters/__init__.py +++ b/pygsp/filters/__init__.py @@ -16,6 +16,8 @@ Filter.evaluate Filter.filter + Filter.analyze + Filter.synthesize Filter.compute_frame Filter.estimate_frame_bounds Filter.plot diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 4316fa38..7919eb1a 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -194,10 +194,10 @@ def filter(self, s, method='chebyshev', order=30): Filter and reconstruct our signal: >>> g = filters.MexicanHat(G, Nf=4) - >>> s2 = g.filter(s1) + >>> s2 = g.analyze(s1) >>> s2.shape (30, 4) - >>> s2 = g.filter(s2) + >>> s2 = g.synthesize(s2) >>> s2.shape (30,) @@ -212,8 +212,8 @@ def filter(self, s, method='chebyshev', order=30): Perfect reconstruction with Itersine, a tight frame: >>> g = filters.Itersine(G) - >>> s2 = g.filter(s1, method='exact') - >>> s2 = g.filter(s2, method='exact') + >>> s2 = g.analyze(s1, method='exact') + >>> s2 = g.synthesize(s2, method='exact') >>> np.linalg.norm(s1 - s2) < 1e-10 True @@ -285,6 +285,24 @@ def filter(self, s, method='chebyshev', order=30): # Return a 1D signal if e.g. a 1D signal was filtered by one filter. return s.squeeze() + def analyze(self, s, method='chebyshev', order=30): + r"""Convenience alias to :meth:`filter`.""" + if s.ndim == 3 and s.shape[-1] != 1: + raise ValueError('Last dimension (#features) should be ' + '1, got {}.'.format(s.shape)) + return self.filter(s, method, order) + + def synthesize(self, s, method='chebyshev', order=30): + r"""Convenience wrapper around :meth:`filter`. + + Will be an alias to `adjoint().filter()` in the future. + """ + if s.shape[-1] != self.Nf: + raise ValueError('Last dimension (#features) should be the number ' + 'of filters Nf = {}, got {}.'.format(self.Nf, + s.shape)) + return self.filter(s, method, order) + def localize(self, i, **kwargs): r"""Localize the kernels at a node (to visualize them). diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 5830faed..9807bd5a 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -87,26 +87,34 @@ def test_filter(self): s2 = s1.reshape((self._G.N, 1)) s3 = g.filter(s1) s4 = g.filter(s2) + s5 = g.analyze(s1) assert s3.shape == (self._G.N, Nf) np.testing.assert_allclose(s3, s4) + np.testing.assert_allclose(s3, s5) s1 = self._rs.uniform(size=(self._G.N, 4)) s2 = s1.reshape((self._G.N, 4, 1)) s3 = g.filter(s1) s4 = g.filter(s2) + s5 = g.analyze(s1) assert s3.shape == (self._G.N, 4, Nf) np.testing.assert_allclose(s3, s4) + np.testing.assert_allclose(s3, s5) s1 = self._rs.uniform(size=(self._G.N, Nf)) s2 = s1.reshape((self._G.N, 1, Nf)) s3 = g.filter(s1) s4 = g.filter(s2) + s5 = g.synthesize(s1) assert s3.shape == (self._G.N,) np.testing.assert_allclose(s3, s4) + np.testing.assert_allclose(s3, s5) s1 = self._rs.uniform(size=(self._G.N, 10, Nf)) s3 = g.filter(s1) + s5 = g.synthesize(s1) assert s3.shape == (self._G.N, 10) + np.testing.assert_allclose(s3, s5) def test_localize(self): G = graphs.Grid2d(20) From ba0cceb6d84c2677eb772bd3774653c202930d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 10:41:48 +0200 Subject: [PATCH 315/392] complete history --- doc/history.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index d0ddaad1..ba4beabf 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -22,10 +22,10 @@ x.x.x (xxxx-xx-xx) * data_handling module merged into utils. * Fourier basis computed with eigh instead of svd (faster). * estimate_lmax uses Lanczos instead of Arnoldi (symmetric sparse). -* Add a seed parameter to various graphs and filters. +* Add a seed parameter to all non-deterministic graphs and filters. * Filter.Nf indicates the number of filters in the filter bank. * Don't check connectedness on graph creation (can take a lot of time). -* Show plots by default with matplotlib backend. +* Erdos-Renyi now implemented as SBM with 1 block. * Many bug fixes (e.g. Minnesota graph, Meyer filter bank, Heat filter, Mexican hat filter bank, Gabor filter bank). * All GitHub issues fixed. @@ -33,6 +33,7 @@ x.x.x (xxxx-xx-xx) Plotting: * Much better handling of plotting parameters. +* With matplotlib backend, plots are shown by default . * Allows to set a default plotting backend as plotting.BACKEND = 'pyqtgraph'. * qtg_default=False becomes backend='matplotlib' * Added coordinates for path, ring, and randomring graphs. @@ -41,6 +42,8 @@ Plotting: * Allows to pass existing matplotlib axes to the plotting functions. * Show colorbar with matplotlib. * Allows to set a 3D view point. +* Eigenvalues shown as vertical lines instead of crosses. +* Vertices can be highlighted, e.g. to show where filters where localized. Documentation: From 1aa8265b9aa3582556c18d2f3328d516e4a170f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 10:51:52 +0200 Subject: [PATCH 316/392] use git clean for make clean --- .gitignore | 2 +- Makefile | 12 +----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index bee062ff..c6c6ba6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -*.py[cod] +__pycache__/ # Packages *.egg diff --git a/Makefile b/Makefile index 4aff6c95..9ad14c09 100644 --- a/Makefile +++ b/Makefile @@ -9,17 +9,7 @@ help: @echo "release package and upload to PyPI" clean: - # Python files. - find . -name '__pycache__' -exec rm -rf {} + - # Documentation. - rm -rf doc/_build - # Coverage. - rm -rf .coverage - rm -rf htmlcov - # Package build. - rm -rf build - rm -rf dist - rm -rf *.egg-info + git clean -Xdf lint: flake8 --doctests --exclude=doc From 7c0ac16aa693bc6788721a885d9f77b688aa62bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 11:21:31 +0200 Subject: [PATCH 317/392] readme: license badge targets license file --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a0618384..f6e04532 100644 --- a/README.rst +++ b/README.rst @@ -15,7 +15,7 @@ PyGSP: Graph Signal Processing in Python :target: https://pypi.python.org/pypi/PyGSP .. image:: https://img.shields.io/pypi/l/pygsp.svg - :target: https://pypi.python.org/pypi/PyGSP + :target: https://github.com/epfl-lts2/pygsp/blob/master/LICENSE.txt .. image:: https://img.shields.io/pypi/pyversions/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP From 5e380f40774aa57d494c48ac5b0ab91f34781525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 11:41:00 +0200 Subject: [PATCH 318/392] readme: order badges --- README.rst | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index f6e04532..2ff0ec88 100644 --- a/README.rst +++ b/README.rst @@ -5,12 +5,6 @@ PyGSP: Graph Signal Processing in Python .. image:: https://readthedocs.org/projects/pygsp/badge/?version=latest :target: https://pygsp.readthedocs.io/en/latest/ -.. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg - :target: https://travis-ci.org/epfl-lts2/pygsp - -.. image:: https://img.shields.io/coveralls/epfl-lts2/pygsp.svg - :target: https://coveralls.io/github/epfl-lts2/pygsp - .. image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP @@ -20,9 +14,19 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/pypi/pyversions/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP +| + +.. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg + :target: https://travis-ci.org/epfl-lts2/pygsp + +.. image:: https://img.shields.io/coveralls/epfl-lts2/pygsp.svg + :target: https://coveralls.io/github/epfl-lts2/pygsp + .. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp +| + The PyGSP is a Python package to ease `Signal Processing on Graphs `_ (a `Matlab counterpart `_ From 6faa3725e90c4e6075c4482575f6893adc2deae3 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 6 Oct 2017 11:47:30 +0200 Subject: [PATCH 319/392] doc: add optimization tutorial with graph tv example --- doc/tutorials/graph_tv.rst | 138 ---------------------------- doc/tutorials/index.rst | 2 +- doc/tutorials/opt_prob_graph_tv.rst | 105 +++++++++++++++++++++ 3 files changed, 106 insertions(+), 139 deletions(-) delete mode 100644 doc/tutorials/graph_tv.rst create mode 100644 doc/tutorials/opt_prob_graph_tv.rst diff --git a/doc/tutorials/graph_tv.rst b/doc/tutorials/graph_tv.rst deleted file mode 100644 index ea5ae869..00000000 --- a/doc/tutorials/graph_tv.rst +++ /dev/null @@ -1,138 +0,0 @@ -=============================================== -Reconstruction of missing samples with graph TV -=============================================== - -.. note:: - The toolbox is **not ready** (yet?) for the completion of that tutorial. - For one, the proximal TV operator on graph is missing. - Please see the `matlab version of that tutorial - `_. - If you like it, implement it! - -Description ------------ - -In this demo, we try to reconstruct missing sample of a piece-wise smooth signal on a graph. To do so, we will minimize the well-known TV norm defined on the graph. - -For this example, you will need the `pyunlocbox `_. - -We express the recovery problem as a convex optimization problem of the following form: - -.. math:: arg \min_x \|\nabla(x)\|_1 \text{ s. t. } \|Mx-b\|_2 \leq \epsilon - -Where :math:`b` represents the known measurements, :math:`M` is an operator representing the mask and :math:`\epsilon` is the radius of the l2 ball. - -We set: - -* :math:`f_1(x)=||\nabla x ||_1` - -We define the prox of :math:`f_1` as: - -.. math:: prox_{f1,\gamma} (z) = arg \min_{x} \frac{1}{2} \|x-z\|_2^2 + \gamma \| \nabla z \|_1 - -* :math:`f_2` is the indicator function of the set S define by :math:||Mx-b||_2 < \epsilon - -We define the prox of :math:`f_2` as - -.. math:: prox_{f2,\gamma} (z) = arg \min_{x} \frac{1}{2} \|x-z\|_2^2 + i_S(x) - -with :math:`i_S(x)` is zero if :math:`x` is in the set :math:`S` and infinity otherwise. -This previous problem has an identical solution as: - -.. math:: arg \min_{z} \|x - z\|_2^2 \hspace{1cm} such \hspace{0.25cm} that \hspace{1cm} \|Mz-b\|_2 \leq \epsilon - -It is simply a projection on the B2-ball. - -Results and code ----------------- - -.. plot:: - :context: reset - - >>> import numpy as np - >>> from pygsp import graphs, plotting - >>> plotting.BACKEND = 'matplotlib' - >>> - >>> # Create a random sensor graph - >>> G = graphs.Sensor(N=256, distribute=True) - >>> G.compute_fourier_basis() - >>> - >>> # Create signal - >>> graph_value = np.copysign(np.ones(np.shape(G.U[:, 3])[0]), G.U[:, 3]) - >>> - >>> G.plot_signal(graph_value) - -This figure shows the original signal on graph. - -.. plot:: - :context: close-figs - - >>> # Create the mask - >>> M = np.random.rand(G.U.shape[0], 1) - >>> M = M > 0.6 # Probability of having no label on a vertex. - >>> - >>> # Applying the mask to the data - >>> sigma = 0.0 - >>> depleted_graph_value = M * (graph_value.reshape(graph_value.size, 1) + sigma * np.random.standard_normal((G.N, 1))) - >>> - >>> G.plot_signal(depleted_graph_value, show_edges=True) - -This figure shows the signal on graph after the application of the -mask and addition of noise. More than half of the vertices are set to 0. - -.. plot:: - :context: close-figs - - >>> # Setting the function f1 (see pyunlocbox for help) - >>> # import pyunlocbox - >>> # import math - >>> - >>> # epsilon = sigma * math.sqrt(np.sum(M[:])) - >>> # operatorA = lambda x: A * x - >>> # f1 = pyunlocbox.functions.proj_b2(y=depleted_graph_value, A=operatorA, At=operatorA, tight=True, epsilon=epsilon) - >>> - >>> # Setting the function ftv - >>> # f2 = pyunlocbox.functions.func() - >>> # f2._prox = lambda x, T: operators.prox_tv(x, T, G, verbose=verbose-1) - >>> # f2._eval = lambda x: operators.norm_tv(G, x) - >>> - >>> # Solve the problem with prox_tv - >>> # ret = pyunlocbox.solvers.solve( - >>> # [f2, f1], - >>> # x0=depleted_graph_value, - >>> # solver=pyunlocbox.solvers.douglas_rachford(), - >>> # atol=1e-7, - >>> # maxit=50, - >>> # verbosity='LOW') - >>> # prox_tv_reconstructed_graph = ret['sol'] - >>> - >>> # G.plot_signal(prox_tv_reconstructed_graph, show_edges=True) - -This figure shows the reconstructed signal thanks to the algorithm. - -Comparison with Tikhonov regularization ---------------------------------------- - -We can also use the Tikhonov regularizer that will promote smoothness. -In this case, we solve: - -.. math:: arg \min_x \tau \|\nabla(x)\|_2^2 \text{ s. t. } \|Mx-b\|_2 \leq \epsilon - -The result is presented as following: - -.. plot:: - :context: close-figs - - >>> # Solve the problem with the same solver as before but with a prox_tik function - >>> # ret = pyunlocbox.solvers.solve( - >>> # [f3, f1], - >>> # x0=depleted_graph_value, - >>> # solver=pyunlocbox.solvers.douglas_rachford(), - >>> # atol=1e-7, - >>> # maxit=50, - >>> # verbosity='LOW') - >>> # prox_tik_reconstructed_graph = ret['sol'] - >>> - >>> # G.plot_signal(prox_tik_reconstructed_graph, show_edges=True) - -This figure shows the reconstructed signal thanks to the algorithm. diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index 0564f255..484f7269 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -10,5 +10,5 @@ how to use it to solve some problems. intro wavelet - graph_tv pyramid + opt_prob_graph_tv diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst new file mode 100644 index 00000000..b2f5f895 --- /dev/null +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -0,0 +1,105 @@ +=========================================================== +Optimization problems: graph TV vs. Tikhonov regularization +=========================================================== + +Description +----------- + +Modern signal processing often involves solving an optimization problem. Graph signal processing (GSP) consists roughly of working with linear operators defined by a graph (e.g., the graph Laplacian). The setting up of an optimization problem in the graph context is often then simply a matter of identifying which graph-defined linear operator is relevant to be used in a regularization and/or fidelity term. + +This tutorial focuses on the problem of recovering a label signal on a graph from subsampled and noisy data, but most information should be fairly generally applied to other problems as well. + +.. plot:: + :context: reset + + >>> import numpy as np + >>> from pygsp import graphs, plotting + >>> plotting.BACKEND = 'matplotlib' + >>> + >>> # Create a random sensor graph + >>> G = graphs.Sensor(N=256, distribute=True) + >>> G.compute_fourier_basis() + >>> + >>> # Create label signal + >>> label_signal = np.copysign(np.ones(G.N), G.U[:, 3]) + >>> + >>> G.plot_signal(label_signal) + +The first figure shows a plot of the original label signal, that we wish to recover, on the graph. + +.. plot:: + :context: close-figs + + >>> # Create the mask + >>> M = np.random.rand(G.N) + >>> M = (M > 0.6).astype(float) # Probability of having no label on a vertex. + >>> + >>> # Applying the mask to the data + >>> sigma = 0.1 + >>> subsampled_noisy_label_signal = M * (label_signal + sigma * np.random.standard_normal(G.N)) + >>> + >>> G.plot_signal(subsampled_noisy_label_signal) + +This figure shows the label signal on the graph after the application of the subsampling mask and the addition of noise. The label of more than half of the vertices has been set to :math:`0`. + +Since the problem is ill-posed, we will use some regularization to reach a solution that is more in tune with what we expect a label signal to look like. We will compare two approaches, but they are both based on measuring local differences on the label signal. Those differences are essentially an edge signal: to each edge we can associate the difference between the label signals of its associated nodes. The linear operator that does such a mapping is called the graph gradient :math:`\nabla_G`, and, fortunately for us, it is available under the :meth:`D` (for differential) attribute of any graph constructed within the :meth:`pygsp`. + +The reason for measuring local differences comes from prior knowledge: we assume label signals don't vary too much locally. The precise measure of such variation is what distinguishes the two regularization approaches we'll use. + +The first one, shown below, is called graph total variation (TV) regularization. The quadratic fidelity term is multiplied by a regularization constant :math:`\gamma` and its goal is to force the solution to stay close to the observed labels :math:`b`. The :math:`\ell_1` norm of the action of the graph gradient is what's called the graph TV. We will see that it promotes piecewise-constant solutions. + +.. math:: arg \min_x \|\nabla_G x\|_1 + \gamma \|Mx-b\|_2^2 + +The second approach, called graph Tikhonov regularization, is to use a smooth (differentiable) quadratic regularizer. A consequence of this choice is that the solution will tend to have smoother transitions. The quadratic fidelity term is still the same. +.. math:: arg \min_x \|\nabla_G x\|_2_2 + \gamma \|Mx-b\|_2^2 + +Results and code +---------------- + +For solving the optimization problems we've assembled, you will need a numerical solver package. This part is implemented in this tutorial with the `pyunlocbox `_, which is based on proximal splitting algorithms. Check also the :meth:`pyunlocbox` `documentation `_ for more information about the parameters used here. + +We start with the graph TV regularization. We will use the :meth:`pyunlocbox.solvers.mlfbf` solver from :meth:`pyunlocbox`. It is a primal-dual solver, which means for our problem that the regularization term will be written in terms of the dual variable :math:`u = \nabla_G x`, and the graph gradient :math:`\nabla_G` will be passed to the solver as the primal-dual map. The value of :math:`3.0` for the regularization parameter :math:`\gamma` was chosen on the basis of the visual appeal of the returned solution. + +.. plot:: + :context: close-figs + + >>> import pyunlocbox + >>> + >>> # Set the functions in the problem + >>> d = pyunlocbox.functions.dummy() + >>> r = pyunlocbox.functions.norm_l1() + >>> f = pyunlocbox.functions.norm_l2(w=M, y=subsampled_noisy_label_signal, + lambda_=3.0) + >>> + >>> # Define the solver + >>> L = G.D.toarray() + >>> step = 0.5 / (1 + np.linalg.norm(L)) + >>> solver = pyunlocbox.solvers.mlfbf(L=L, step=step) + >>> + >>> # Solve the problem + >>> x0 = subsampled_noisy_label_signal.copy() + >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, + x0=x0, atol=1e-7, rtol=0, maxit=200, verbosity='LOW') + >>> + >>> G.plot_signal(prob1['sol']) + +This figure shows the label signal recovered by graph total variation regularization. We can confirm here that this sort of regularization does indeed promote piecewise-constant solutions. + +.. plot:: + :context: close-figs + + >>> # Set the functions in the problem + >>> r = pyunlocbox.functions.norm_l2(A=L, tight=False) + >>> + >>> # Define the solver + >>> step = 0.5 / np.linalg.norm(np.dot(L.T, L) + 3.0 * np.diag(M), 2) + >>> solver = pyunlocbox.solvers.gradient_descent(step=step) + >>> + >>> # Solve the problem + >>> x0 = subsampled_noisy_label_signal.copy() + >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, + x0=x0, atol=1e-7, rtol=0, maxit=200, verbosity='LOW') + >>> + >>> G.plot_signal(prob2['sol']) + +This last figure shows the label signal recovered by Tikhonov regularization. As expected, the recovered label signal has smoother transitions than the one obtained by graph TV regularization. From dc03202d97f2a8e0cf9a786634de8c9eaed197ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 12:26:04 +0200 Subject: [PATCH 320/392] setup: pyunlocbox now used in doctests --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index c89af496..5916c01f 100644 --- a/setup.py +++ b/setup.py @@ -36,11 +36,13 @@ ], extras_require={ 'test': [ + 'pyunlocbox', 'flake8', 'coverage', 'coveralls', ], 'doc': [ + 'pyunlocbox', 'sphinx', 'numpydoc', 'sphinxcontrib-bibtex', From 0e6eee73ec69dbcd859412c32793109124509350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 12:29:52 +0200 Subject: [PATCH 321/392] remove unused sanitize_signal --- pygsp/graphs/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pygsp/graphs/__init__.py b/pygsp/graphs/__init__.py index a844a74f..891efc3b 100644 --- a/pygsp/graphs/__init__.py +++ b/pygsp/graphs/__init__.py @@ -86,7 +86,6 @@ Graph.set_coordinates Graph.subgraph Graph.extract_components - Graph.sanitize_signal Graph models ============ From 9094e503f8b91ec025736fcedfca83d7f4161bdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 12:45:43 +0200 Subject: [PATCH 322/392] optimization tutorial: continuation lines --- doc/tutorials/opt_prob_graph_tv.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst index b2f5f895..60f0af87 100644 --- a/doc/tutorials/opt_prob_graph_tv.rst +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -69,7 +69,7 @@ We start with the graph TV regularization. We will use the :meth:`pyunlocbox.sol >>> d = pyunlocbox.functions.dummy() >>> r = pyunlocbox.functions.norm_l1() >>> f = pyunlocbox.functions.norm_l2(w=M, y=subsampled_noisy_label_signal, - lambda_=3.0) + ... lambda_=3.0) >>> >>> # Define the solver >>> L = G.D.toarray() @@ -79,7 +79,8 @@ We start with the graph TV regularization. We will use the :meth:`pyunlocbox.sol >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, - x0=x0, atol=1e-7, rtol=0, maxit=200, verbosity='LOW') + ... x0=x0, atol=1e-7, rtol=0, maxit=200, + ... verbosity='LOW') >>> >>> G.plot_signal(prob1['sol']) @@ -98,7 +99,8 @@ This figure shows the label signal recovered by graph total variation regulariza >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, - x0=x0, atol=1e-7, rtol=0, maxit=200, verbosity='LOW') + ... x0=x0, atol=1e-7, rtol=0, maxit=200, + ... verbosity='LOW') >>> >>> G.plot_signal(prob2['sol']) From 335b3aa34f79409e8a839bb8ae48f15709ebe7b8 Mon Sep 17 00:00:00 2001 From: rodrigo-pena Date: Fri, 6 Oct 2017 13:17:28 +0200 Subject: [PATCH 323/392] fix optimization tutorial --- doc/tutorials/opt_prob_graph_tv.rst | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst index 60f0af87..348f7bb6 100644 --- a/doc/tutorials/opt_prob_graph_tv.rst +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -17,7 +17,7 @@ This tutorial focuses on the problem of recovering a label signal on a graph fro >>> plotting.BACKEND = 'matplotlib' >>> >>> # Create a random sensor graph - >>> G = graphs.Sensor(N=256, distribute=True) + >>> G = graphs.Sensor(N=256, distribute=True, seed=42) >>> G.compute_fourier_basis() >>> >>> # Create label signal @@ -30,13 +30,15 @@ The first figure shows a plot of the original label signal, that we wish to reco .. plot:: :context: close-figs + >>> rs = np.random.RandomState(42) + >>> >>> # Create the mask - >>> M = np.random.rand(G.N) + >>> M = rs.rand(G.N) >>> M = (M > 0.6).astype(float) # Probability of having no label on a vertex. >>> >>> # Applying the mask to the data >>> sigma = 0.1 - >>> subsampled_noisy_label_signal = M * (label_signal + sigma * np.random.standard_normal(G.N)) + >>> subsampled_noisy_label_signal = M * (label_signal + sigma * rs.standard_normal(G.N)) >>> >>> G.plot_signal(subsampled_noisy_label_signal) @@ -66,20 +68,22 @@ We start with the graph TV regularization. We will use the :meth:`pyunlocbox.sol >>> import pyunlocbox >>> >>> # Set the functions in the problem + >>> gamma = 3.0 >>> d = pyunlocbox.functions.dummy() >>> r = pyunlocbox.functions.norm_l1() >>> f = pyunlocbox.functions.norm_l2(w=M, y=subsampled_noisy_label_signal, - ... lambda_=3.0) + ... lambda_=gamma) >>> >>> # Define the solver + >>> G.compute_differential_operator() >>> L = G.D.toarray() - >>> step = 0.5 / (1 + np.linalg.norm(L)) + >>> step = 0.999 / (1 + np.linalg.norm(L)) >>> solver = pyunlocbox.solvers.mlfbf(L=L, step=step) >>> >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, - ... x0=x0, atol=1e-7, rtol=0, maxit=200, + ... x0=x0, rtol=0, maxit=1000, ... verbosity='LOW') >>> >>> G.plot_signal(prob1['sol']) @@ -93,13 +97,13 @@ This figure shows the label signal recovered by graph total variation regulariza >>> r = pyunlocbox.functions.norm_l2(A=L, tight=False) >>> >>> # Define the solver - >>> step = 0.5 / np.linalg.norm(np.dot(L.T, L) + 3.0 * np.diag(M), 2) + >>> step = 0.999 / np.linalg.norm(np.dot(L.T, L) + gamma * np.diag(M), 2) >>> solver = pyunlocbox.solvers.gradient_descent(step=step) >>> >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, - ... x0=x0, atol=1e-7, rtol=0, maxit=200, + ... x0=x0, rtol=0, maxit=1000, ... verbosity='LOW') >>> >>> G.plot_signal(prob2['sol']) From 9019743541ec577361b0c59bfb5d72c2ef725945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:01:24 +0200 Subject: [PATCH 324/392] doc: reference pyunlocbox documentation with intersphinx --- doc/conf.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 8b95c962..e9b45753 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -12,6 +12,11 @@ autodoc_default_flags = ['members', 'undoc-members'] autodoc_member_order = 'groupwise' # alphabetical, groupwise, bysource +extensions.append('sphinx.ext.intersphinx') +intersphinx_mapping = { + 'pyunlocbox': ('https://pyunlocbox.readthedocs.io/en/stable', None) +} + extensions.append('numpydoc') numpydoc_show_class_members = False numpydoc_use_plots = True # Add the plot directive whenever mpl is imported. From bbd28547cbd514a00bf64598d8e2a8e50f7ff907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:22:39 +0200 Subject: [PATCH 325/392] doc: fix wavelet tutorial Do not use SKIP to avoid errors to not be detected by doctest. --- doc/tutorials/intro.rst | 8 ++++---- doc/tutorials/wavelet.rst | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index c7e38d00..dca40e6a 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -152,7 +152,7 @@ which assign a set of values (a vector in :math:`\mathbb{R}^d`) at every node >>> fig, axes = plt.subplots(1, 2, figsize=(10, 3)) >>> for i, ax in enumerate(axes): ... G.plot_signal(G.U[:, i+1], vertex_size=30, ax=ax) - ... ax.set_title('Eigenvector {}'.format(i+2)) #doctest:+SKIP + ... _ = ax.set_title('Eigenvector {}'.format(i+2)) ... ax.set_axis_off() >>> fig.tight_layout() @@ -194,7 +194,7 @@ let's define and plot that low-pass filter: >>> >>> fig, ax = plt.subplots() >>> g.plot(plot_eigenvalues=True, ax=ax) - >>> ax.set_title('Filter frequency response') #doctest:+SKIP + >>> _ = ax.set_title('Filter frequency response') The filter is plotted along all the spectrum of the graph. The black crosses are the eigenvalues of the Laplacian. They are the points where the continuous @@ -230,10 +230,10 @@ low-pass filter. >>> >>> fig, axes = plt.subplots(1, 2, figsize=(10, 3)) >>> G.plot_signal(s, vertex_size=30, ax=axes[0]) - >>> axes[0].set_title('Noisy signal') #doctest:+SKIP + >>> _ = axes[0].set_title('Noisy signal') >>> axes[0].set_axis_off() >>> G.plot_signal(s2, vertex_size=30, ax=axes[1]) - >>> axes[1].set_title('Cleaned signal') #doctest:+SKIP + >>> _ = axes[1].set_title('Cleaned signal') >>> axes[1].set_axis_off() >>> fig.tight_layout() diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index 7d01981e..375dd76b 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -76,11 +76,11 @@ scales. >>> fig = plt.figure(figsize=(10, 3)) >>> for i in range(g.Nf): ... ax = fig.add_subplot(1, g.Nf, i+1, projection='3d') - ... G.plot_signal(s[:, 0, i], colorbar=False, ax=ax) + ... G.plot_signal(s[:, i], colorbar=False, ax=ax) ... title = r'Heat diffusion, $\tau={}$'.format(taus[i]) - ... ax.set_title(title) #doctest:+SKIP + ... _ = ax.set_title(title) ... ax.set_axis_off() - >>> fig.tight_layout() # doctest:+SKIP + >>> fig.tight_layout() .. note:: The :meth:`pygsp.filters.Filter.localize` method can be used to visualize a @@ -108,7 +108,7 @@ Then plot the frequency response of those filters. >>> fig, ax = plt.subplots(figsize=(10, 5)) >>> g.plot(ax=ax) - >>> ax.set_title('Filter bank of mexican hat wavelets') # doctest:+SKIP + >>> _ = ax.set_title('Filter bank of mexican hat wavelets') .. note:: We can see that the wavelet atoms are stacked on the low frequency part of @@ -127,10 +127,10 @@ a Kronecker delta placed at one specific vertex. >>> fig = plt.figure(figsize=(10, 2.5)) >>> for i in range(3): ... ax = fig.add_subplot(1, 3, i+1, projection='3d') - ... G.plot_signal(s[:, 0, i], ax=ax) - ... ax.set_title('Wavelet {}'.format(i+1)) #doctest:+SKIP + ... G.plot_signal(s[:, i], ax=ax) + ... _ = ax.set_title('Wavelet {}'.format(i+1)) ... ax.set_axis_off() - >>> fig.tight_layout() # doctest:+SKIP + >>> fig.tight_layout() Curvature estimation -------------------- @@ -170,6 +170,6 @@ curvature at different scales. ... ax = fig.add_subplot(2, 2, i+1, projection='3d') ... G.plot_signal(s[:, i], ax=ax) ... title = 'Curvature estimation (scale {})'.format(i+1) - ... ax.set_title(title) # doctest:+SKIP + ... _ = ax.set_title(title) ... ax.set_axis_off() - >>> fig.tight_layout() # doctest:+SKIP + >>> fig.tight_layout() From 6ed63a6bc4900191532f916a6a9eb3ff2845f8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:33:46 +0200 Subject: [PATCH 326/392] optimization tutorial: show outputs --- doc/tutorials/opt_prob_graph_tv.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst index 348f7bb6..56e80d20 100644 --- a/doc/tutorials/opt_prob_graph_tv.rst +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -85,6 +85,9 @@ We start with the graph TV regularization. We will use the :meth:`pyunlocbox.sol >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, ... x0=x0, rtol=0, maxit=1000, ... verbosity='LOW') + Solution found after 1000 iterations: + objective function f(sol) = 2.024594e+02 + stopping criterion: MAXIT >>> >>> G.plot_signal(prob1['sol']) @@ -105,6 +108,9 @@ 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, ... verbosity='LOW') + Solution found after 1000 iterations: + objective function f(sol) = 9.555135e+01 + stopping criterion: MAXIT >>> >>> G.plot_signal(prob2['sol']) From e2a6265419ec83432611d0a5e4124658f0b914ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:45:30 +0200 Subject: [PATCH 327/392] optimization tutorial: fix references --- doc/tutorials/opt_prob_graph_tv.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst index 56e80d20..fbaa96a8 100644 --- a/doc/tutorials/opt_prob_graph_tv.rst +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -44,7 +44,7 @@ The first figure shows a plot of the original label signal, that we wish to reco This figure shows the label signal on the graph after the application of the subsampling mask and the addition of noise. The label of more than half of the vertices has been set to :math:`0`. -Since the problem is ill-posed, we will use some regularization to reach a solution that is more in tune with what we expect a label signal to look like. We will compare two approaches, but they are both based on measuring local differences on the label signal. Those differences are essentially an edge signal: to each edge we can associate the difference between the label signals of its associated nodes. The linear operator that does such a mapping is called the graph gradient :math:`\nabla_G`, and, fortunately for us, it is available under the :meth:`D` (for differential) attribute of any graph constructed within the :meth:`pygsp`. +Since the problem is ill-posed, we will use some regularization to reach a solution that is more in tune with what we expect a label signal to look like. We will compare two approaches, but they are both based on measuring local differences on the label signal. Those differences are essentially an edge signal: to each edge we can associate the difference between the label signals of its associated nodes. The linear operator that does such a mapping is called the graph gradient :math:`\nabla_G`, and, fortunately for us, it is available under the :attr:`pygsp.graphs.Graph.D` (for differential) attribute of any :mod:`pygsp.graphs` graph. The reason for measuring local differences comes from prior knowledge: we assume label signals don't vary too much locally. The precise measure of such variation is what distinguishes the two regularization approaches we'll use. @@ -58,9 +58,9 @@ The second approach, called graph Tikhonov regularization, is to use a smooth (d Results and code ---------------- -For solving the optimization problems we've assembled, you will need a numerical solver package. This part is implemented in this tutorial with the `pyunlocbox `_, which is based on proximal splitting algorithms. Check also the :meth:`pyunlocbox` `documentation `_ for more information about the parameters used here. +For solving the optimization problems we've assembled, you will need a numerical solver package. This part is implemented in this tutorial with the `pyunlocbox `_, which is based on proximal splitting algorithms. Check also its `documentation `_ for more information about the parameters used here. -We start with the graph TV regularization. We will use the :meth:`pyunlocbox.solvers.mlfbf` solver from :meth:`pyunlocbox`. It is a primal-dual solver, which means for our problem that the regularization term will be written in terms of the dual variable :math:`u = \nabla_G x`, and the graph gradient :math:`\nabla_G` will be passed to the solver as the primal-dual map. The value of :math:`3.0` for the regularization parameter :math:`\gamma` was chosen on the basis of the visual appeal of the returned solution. +We start with the graph TV regularization. We will use the :class:`pyunlocbox.solvers.mlfbf` solver from :mod:`pyunlocbox`. It is a primal-dual solver, which means for our problem that the regularization term will be written in terms of the dual variable :math:`u = \nabla_G x`, and the graph gradient :math:`\nabla_G` will be passed to the solver as the primal-dual map. The value of :math:`3.0` for the regularization parameter :math:`\gamma` was chosen on the basis of the visual appeal of the returned solution. .. plot:: :context: close-figs From 56b5a0b32b7389294048d477da52330b8d826fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:45:52 +0200 Subject: [PATCH 328/392] optimization tutorial: verbosity LOW is the default --- doc/tutorials/opt_prob_graph_tv.rst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/opt_prob_graph_tv.rst index fbaa96a8..ae481235 100644 --- a/doc/tutorials/opt_prob_graph_tv.rst +++ b/doc/tutorials/opt_prob_graph_tv.rst @@ -83,8 +83,7 @@ We start with the graph TV regularization. We will use the :class:`pyunlocbox.so >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob1 = pyunlocbox.solvers.solve([d, r, f], solver=solver, - ... x0=x0, rtol=0, maxit=1000, - ... verbosity='LOW') + ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: objective function f(sol) = 2.024594e+02 stopping criterion: MAXIT @@ -106,8 +105,7 @@ This figure shows the label signal recovered by graph total variation regulariza >>> # Solve the problem >>> x0 = subsampled_noisy_label_signal.copy() >>> prob2 = pyunlocbox.solvers.solve([r, f], solver=solver, - ... x0=x0, rtol=0, maxit=1000, - ... verbosity='LOW') + ... x0=x0, rtol=0, maxit=1000) Solution found after 1000 iterations: objective function f(sol) = 9.555135e+01 stopping criterion: MAXIT From f3dc673b5f78b58a28598713b920a5807ce3f691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:48:11 +0200 Subject: [PATCH 329/392] optimization tutorial: rename --- doc/tutorials/index.rst | 2 +- doc/tutorials/{opt_prob_graph_tv.rst => optimization.rst} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename doc/tutorials/{opt_prob_graph_tv.rst => optimization.rst} (100%) diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index 484f7269..66c9073c 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -10,5 +10,5 @@ how to use it to solve some problems. intro wavelet + optimization pyramid - opt_prob_graph_tv diff --git a/doc/tutorials/opt_prob_graph_tv.rst b/doc/tutorials/optimization.rst similarity index 100% rename from doc/tutorials/opt_prob_graph_tv.rst rename to doc/tutorials/optimization.rst From 70cd779f722ec27e97dbdb73a97132f253e642c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 13:53:02 +0200 Subject: [PATCH 330/392] version 0.5.0 --- doc/history.rst | 2 +- pygsp/__init__.py | 4 ++-- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index ba4beabf..86d15775 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -2,7 +2,7 @@ History ======= -x.x.x (xxxx-xx-xx) +0.5.0 (2017-10-06) ------------------ * Generalized the analysis and synthesis methods into the filter method. diff --git a/pygsp/__init__.py b/pygsp/__init__.py index dbb602df..3fac4174 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -30,5 +30,5 @@ _utils.import_modules(__all__[::-1], 'pygsp', 'pygsp') -__version__ = '0.4.2' -__release_date__ = '2017-04-27' +__version__ = '0.5.0' +__release_date__ = '2017-10-06' diff --git a/setup.py b/setup.py index 5916c01f..a36ff274 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='PyGSP', - version='0.4.2', + version='0.5.0', description='Graph Signal Processing in Python', long_description=open('README.rst').read(), author='EPFL LTS2', From 7854d0ae3ada582f19b2671b13863c87baddd849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 15:15:30 +0200 Subject: [PATCH 331/392] readme: point to zenodo DOI record --- README.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.rst b/README.rst index 2ff0ec88..164e7d8c 100644 --- a/README.rst +++ b/README.rst @@ -8,6 +8,9 @@ PyGSP: Graph Signal Processing in Python .. image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP +.. image:: https://zenodo.org/badge/16276560.svg + :target: https://doi.org/10.5281/zenodo.1003157 + .. image:: https://img.shields.io/pypi/l/pygsp.svg :target: https://github.com/epfl-lts2/pygsp/blob/master/LICENSE.txt @@ -100,3 +103,7 @@ The PyGSP was started in 2014 as an academic open-source project for research purpose at the `EPFL LTS2 laboratory `_. This project has been partly funded by the Swiss National Science Foundation under grant 200021_154350 "Towards Signal Processing on Graphs". + +If you are using the library for your research, for the sake of +reproducibility, please cite the version you used as indexed by +`Zenodo `_. From 52d7d4c68adaeab1141a49ae7ace427fd9467e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 15:16:31 +0200 Subject: [PATCH 332/392] more deterministic tests --- pygsp/filters/filter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 7919eb1a..c68f1c08 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -376,14 +376,14 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, Examples -------- >>> G = graphs.Logo() - >>> G.estimate_lmax() + >>> G.compute_fourier_basis() >>> f = filters.MexicanHat(G) Bad estimation: >>> A, B = f.estimate_frame_bounds(min=1, max=20, N=100) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=0.126, B=0.270 + A=0.125, B=0.270 Better estimation: @@ -396,7 +396,7 @@ def estimate_frame_bounds(self, min=0, max=None, N=1000, >>> G.compute_fourier_basis() >>> A, B = f.estimate_frame_bounds(use_eigenvalues=True) >>> print('A={:.3f}, B={:.3f}'.format(A, B)) - A=0.178, B=0.270 + A=0.177, B=0.270 The Itersine filter bank defines a tight frame: From 2e99c119abf499ecb00a1e2834ffbd89156af17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 15:43:39 +0200 Subject: [PATCH 333/392] contributing: instructions to make a release --- CONTRIBUTING.rst | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a7cb5ff3..07516702 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -36,6 +36,28 @@ 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. +Making a release +---------------- + +#. Update the version number and release date in ``setup.py``, + ``pygsp/__init__.py`` and ``doc/history.rst``. +#. Create a git tag with ``git tag -a v0.5.0 -m "PyGSP v0.5.0"``. +#. Push the tag to GitHub with ``git push github v0.5.0``. The tag should now + appear in the releases and tags tab. +#. `Create a release `_ on + GitHub and select the created tag. A DOI should then be issued by Zenodo. +#. Go on Zenodo and fix the metadata if necessary. +#. Build the distribution with ``make dist`` and check that the + ``dist/PyGSP-0.5.0.tar.gz`` source archive contains all required files. The + binary wheel should be found as ``dist/PyGSP-0.5.0-py2.py3-none-any.whl``. +#. Test the upload and installation process:: + + $ twine upload --repository-url https://test.pypi.org/legacy/ dist/* + $ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple pygsp + + Log in as the LTS2 user. +#. Build and upload the distribution to the real PyPI with ``make release``. + Repository organization ----------------------- From a267ac6401cef1bafd8ded31eda15087a194786f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 6 Oct 2017 16:06:11 +0200 Subject: [PATCH 334/392] readme: reference default rtd doc --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 164e7d8c..9e1f4f40 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ PyGSP: Graph Signal Processing in Python ======================================== .. image:: https://readthedocs.org/projects/pygsp/badge/?version=latest - :target: https://pygsp.readthedocs.io/en/latest/ + :target: https://pygsp.readthedocs.io .. image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP From ebfe292f65f4f5f0dc41814ff147c82376ee226d Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Thu, 23 Nov 2017 19:23:13 +0100 Subject: [PATCH 335/392] move the degree to a property + fix gradient computation for the normalized case --- pygsp/graphs/difference.py | 4 ++-- pygsp/graphs/graph.py | 36 ++++++++++++++++++++-------- pygsp/graphs/sensor.py | 1 - pygsp/graphs/stochasticblockmodel.py | 1 - pygsp/tests/test_graphs.py | 15 ++++++++++++ 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/pygsp/graphs/difference.py b/pygsp/graphs/difference.py index eab3f5c4..ffc4e7f4 100644 --- a/pygsp/graphs/difference.py +++ b/pygsp/graphs/difference.py @@ -67,8 +67,8 @@ def compute_differential_operator(self): Dv[:n] = np.sqrt(weights) Dv[n:] = -Dv[:n] elif self.lap_type == 'normalized': - Dv[:n] = np.sqrt(weights / self.d[v_in]) - Dv[n:] = -np.sqrt(weights / self.d[v_out]) + Dv[:n] = np.sqrt(weights / self.dw[v_in]) + Dv[n:] = -np.sqrt(weights / self.dw[v_out]) else: raise ValueError('Unknown lap_type {}'.format(self.lap_type)) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 74d4fca8..39ebe791 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -45,13 +45,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): 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. - A : sparse matrix or ndarray - the adjacency matrix defines which edges exist on the graph. - It is represented as an N-by-N matrix of booleans. - :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. - d : ndarray - the degree vector is a vector of length N which represents the number - of edges connected to each node. gtype : string the graph type is a short description of the graph object designed to help sorting the graphs. @@ -97,9 +90,6 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', self.check_weights() - self.A = self.W > 0 - self.d = np.asarray(self.A.sum(axis=1)).squeeze() - assert self.d.ndim == 1 self.gtype = gtype self.compute_laplacian(lap_type) @@ -607,6 +597,32 @@ def compute_laplacian(self, lap_type='combinatorial'): D = sparse.diags(np.ravel(d), 0).tocsc() self.L = sparse.identity(self.N) - D * self.W * D + + @property + def A(self): + r"""Return the adjacency matrix. + + The adjacency matrix defines which edges exist on the graph. + It is represented as an N-by-N matrix of booleans. + :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. + + """ + return self.W > 0 + + @property + def d(self): + r"""Return the number of neighboors of each nodes of the graph.""" + if not hasattr(self, '_d'): + self._d = np.asarray(self.A.sum(axis=1)).squeeze() + return self._d + + @property + def dw(self): + r"""Return the weighted degree of each nodes of the graph.""" + if not hasattr(self, '_dw'): + self._dw = np.asarray(self.W.sum(axis=1)).squeeze() + return self._dw + @property def lmax(self): r"""Largest eigenvalue of the graph Laplacian. diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 9dcf8b0f..8d5b14a1 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -53,7 +53,6 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, W, coords = self._create_weight_matrix(N, distribute, regular, Nc) self.W = W - self.A = W > 0 if self.is_connected(recompute=True): break diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 6303c43a..2d6e9514 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -122,7 +122,6 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, break else: self.W = W - self.A = (W > 0) if self.is_connected(recompute=True): break if nb_iter == max_iter - 1: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 0a3d8fef..5ba082fb 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -43,6 +43,21 @@ def test_graph(self): self.assertEqual(ki.shape[0], G.Ne) self.assertEqual(kj.shape[0], G.Ne) + def test_degree(self): + W = np.arange(1,17).reshape(4, 4) + G = graphs.Graph(W) + d = 4*np.ones([4]) + dw = np.sum(W,axis=1).squeeze() + self.assertAlmostEqual(np.linalg.norm(G.d-d),0) + self.assertAlmostEqual(np.linalg.norm(G.dw-dw),0) + + G = graphs.Sensor() + A = G.W>0 + d = np.sum(A.toarray(),axis=1).squeeze() + dw = np.sum(G.W.toarray(),axis=1).squeeze() + self.assertAlmostEqual(np.linalg.norm(G.d-d),0) + self.assertAlmostEqual(np.linalg.norm(G.dw-dw),0) + def test_laplacian(self): # TODO: should test correctness. From 71de1602b10ef3887032e95f84a5b2bcc14d2d29 Mon Sep 17 00:00:00 2001 From: Nathanael Perraudin Date: Thu, 23 Nov 2017 19:26:12 +0100 Subject: [PATCH 336/392] correct the wrongly used degree --- 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 39ebe791..d97a4669 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -593,7 +593,7 @@ def compute_laplacian(self, lap_type='combinatorial'): self.L = (D - self.W).tocsc() elif lap_type == 'normalized': - d = np.power(self.W.sum(1), -0.5) + d = np.power(self.dw, -0.5) D = sparse.diags(np.ravel(d), 0).tocsc() self.L = sparse.identity(self.N) - D * self.W * D @@ -692,7 +692,7 @@ def estimate_lmax(self, recompute=False): if self.lap_type == 'normalized': lmax = 2 # Spectrum is bounded by [0, 2]. elif self.lap_type == 'combinatorial': - lmax = 2 * np.max(self.d) + lmax = 2 * np.max(self.dw) else: raise ValueError('Unknown Laplacian type ' '{}'.format(self.lap_type)) From 79ba6fbec660c4728275560ea92229b57577ea62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 24 Nov 2017 01:16:47 +0100 Subject: [PATCH 337/392] graphs: store the adjacency matrix, as the degrees --- 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 d97a4669..9b233670 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -607,7 +607,9 @@ def A(self): :math:`A_{i,j}` is True if :math:`W_{i,j} > 0`. """ - return self.W > 0 + if not hasattr(self, '_A'): + self._A = self.W > 0 + return self._A @property def d(self): From 650041d37740aeb8e8559aaa8e5d0de008f9b8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 24 Nov 2017 01:17:24 +0100 Subject: [PATCH 338/392] randomregular: use new properties --- pygsp/graphs/randomregular.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 43a6fe61..b56e715b 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -111,30 +111,23 @@ def is_regular(self): warn = False msg = 'The given matrix' - # check if the sparse matrix is in a good format - A = self.A - if A.getformat() in ['lil', 'dia', 'bok']: - A = A.tocsc() - # check symmetry - tmp = A - A.T - if np.abs(tmp).sum() > 0: + if np.abs(self.A - self.A.T).sum() > 0: warn = True msg = '{} is not symmetric,'.format(msg) # check parallel edged - if A.max(axis=None) > 1: + if self.A.max(axis=None) > 1: warn = True msg = '{} has parallel edges,'.format(msg) # check that d is d-regular - d_vec = A.sum(axis=0) - if np.min(d_vec) != np.max(d_vec): + if np.min(self.d) != np.max(self.d): warn = True msg = '{} is not d-regular,'.format(msg) # check that g doesn't contain any self-loop - if A.diagonal().any(): + if self.A.diagonal().any(): warn = True msg = '{} has self loop.'.format(msg) From 3122a560fefc7949008d8b269535caf23392612a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 24 Nov 2017 01:30:58 +0100 Subject: [PATCH 339/392] graphs: update doc --- pygsp/graphs/graph.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 9b233670..7deba8d7 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -40,7 +40,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): Ne : int the number of edges / links in the graph, i.e. connections between nodes. - W : sparse matrix or ndarray + 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 @@ -48,7 +48,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): gtype : string the graph type is a short description of the graph object designed to help sorting the graphs. - L : sparse matrix or ndarray + L : sparse 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`. @@ -600,12 +600,11 @@ def compute_laplacian(self, lap_type='combinatorial'): @property def A(self): - r"""Return the adjacency matrix. + r"""Graph adjacency matrix (the binary version of W). The adjacency matrix defines which edges exist on the graph. 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'): self._A = self.W > 0 @@ -613,14 +612,14 @@ def A(self): @property def d(self): - r"""Return the number of neighboors of each nodes of the graph.""" + r"""The degree (the number of neighbors) of each node.""" if not hasattr(self, '_d'): self._d = np.asarray(self.A.sum(axis=1)).squeeze() return self._d @property def dw(self): - r"""Return the weighted degree of each nodes of the graph.""" + r"""The weighted degree (the sum of weighted edges) of each node.""" if not hasattr(self, '_dw'): self._dw = np.asarray(self.W.sum(axis=1)).squeeze() return self._dw From 508545f2c9a982a38a243ec95c7a5cdd266ed358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 24 Nov 2017 01:34:19 +0100 Subject: [PATCH 340/392] tests: do not test using the implementation --- pygsp/tests/test_graphs.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index 5ba082fb..dc51fec5 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -44,19 +44,12 @@ def test_graph(self): self.assertEqual(kj.shape[0], G.Ne) def test_degree(self): - W = np.arange(1,17).reshape(4, 4) + W = 0.3 * (np.ones((4, 4)) - np.diag(4 * [1])) G = graphs.Graph(W) - d = 4*np.ones([4]) - dw = np.sum(W,axis=1).squeeze() - self.assertAlmostEqual(np.linalg.norm(G.d-d),0) - self.assertAlmostEqual(np.linalg.norm(G.dw-dw),0) - - G = graphs.Sensor() - A = G.W>0 - d = np.sum(A.toarray(),axis=1).squeeze() - dw = np.sum(G.W.toarray(),axis=1).squeeze() - self.assertAlmostEqual(np.linalg.norm(G.d-d),0) - self.assertAlmostEqual(np.linalg.norm(G.dw-dw),0) + 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) def test_laplacian(self): # TODO: should test correctness. From f7bc5924cf6e65a78eb5eb06fc8dc6c985635f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 9 Dec 2017 15:28:20 +0100 Subject: [PATCH 341/392] optimization.rst: correct math display --- doc/tutorials/optimization.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index ae481235..41bd729a 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -50,10 +50,11 @@ The reason for measuring local differences comes from prior knowledge: we assume The first one, shown below, is called graph total variation (TV) regularization. The quadratic fidelity term is multiplied by a regularization constant :math:`\gamma` and its goal is to force the solution to stay close to the observed labels :math:`b`. The :math:`\ell_1` norm of the action of the graph gradient is what's called the graph TV. We will see that it promotes piecewise-constant solutions. -.. math:: arg \min_x \|\nabla_G x\|_1 + \gamma \|Mx-b\|_2^2 +.. math:: \operatorname*{arg\,min}_x \|\nabla_G x\|_1 + \gamma \|Mx-b\|_2^2 The second approach, called graph Tikhonov regularization, is to use a smooth (differentiable) quadratic regularizer. A consequence of this choice is that the solution will tend to have smoother transitions. The quadratic fidelity term is still the same. -.. math:: arg \min_x \|\nabla_G x\|_2_2 + \gamma \|Mx-b\|_2^2 + +.. math:: \operatorname*{arg\,min}_x \|\nabla_G x\|_2^2 + \gamma \|Mx-b\|_2^2 Results and code ---------------- From 1329e31fc177c89c37c2399b34529841ec9a141c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 9 Dec 2017 15:28:51 +0100 Subject: [PATCH 342/392] doc: method was renamed --- pygsp/filters/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index c68f1c08..472e07cb 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -319,7 +319,7 @@ def localize(self, i, **kwargs): i : int Index of the node where to localize the kernel. kwargs: dict - Parameters to be passed to the :meth:`analysis` method. + Parameters to be passed to the :meth:`analyze` method. Returns ------- @@ -439,7 +439,7 @@ def compute_frame(self, **kwargs): Parameters ---------- kwargs: dict - Parameters to be passed to the :meth:`analysis` method. + Parameters to be passed to the :meth:`analyze` method. Returns ------- From 949e739aa5d81b4caf0d6530fd5bd2b715747868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 10:58:06 +0100 Subject: [PATCH 343/392] remove old parameter --- pygsp/graphs/graph.py | 2 -- pygsp/graphs/nngraphs/grid2dimgpatches.py | 1 - pygsp/graphs/nngraphs/imgpatches.py | 1 - 3 files changed, 4 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7deba8d7..5656c245 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -30,8 +30,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): Vertices coordinates (default is None). plotting : dict Plotting parameters. - perform_checks : bool - Whether to check if the graph is connected. Warn if not. Attributes ---------- diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 2ab5a258..40f5b93e 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -43,5 +43,4 @@ def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, gtype=gtype, coords=Gg.coords, plotting=Gg.plotting, - perform_all_checks=False, **kwargs) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 8b45305c..f0193eeb 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -43,6 +43,5 @@ def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, use_flann=True, symmetrize_type=symmetrize_type, dist_type=dist_type, gtype='patch-graph', - perform_all_checks=False, **kwargs) self.img = img From 948d63224d75d1b16878a6e38cfe174e5a8adff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 11:30:45 +0100 Subject: [PATCH 344/392] graph: remove useless and unmaintained methods Those methods were ported from the matlab version and are not useful anymore. update_graph_attr: it's much simpler and predictable to build a new graph object with the updated weighted adjacency matrix. The dependent matrices (laplacian, difference, Fourier) will then have to be explicitly recomputed by the user. Other attributes (e.g. coords, plotting) can be copied manually by the user. copy_graph_attributes: the graph object can be copied with the standard Python copy. --- pygsp/graphs/graph.py | 98 ------------------------------------------- pygsp/reduction.py | 4 +- 2 files changed, 2 insertions(+), 100 deletions(-) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 5656c245..62e3c18c 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -157,104 +157,6 @@ def check_weights(self): 'is_not_square': is_not_square, 'diag_is_not_zero': diag_is_not_zero} - def update_graph_attr(self, *args, **kwargs): - r"""Recompute some attribute of the graph. - - Parameters - ---------- - args: list of string - the arguments that will be not changed and not re-compute. - kwargs: Dictionnary - The arguments with their new value. - - Returns - ------- - The same Graph with some updated values. - - Notes - ----- - This method is useful if you want to give a new weight matrix - (W) and compute the adjacency matrix (A) and more again. - The valid attributes are ['W', 'A', 'N', 'd', 'Ne', 'gtype', - 'directed', 'coords', 'lap_type', 'L', 'plotting'] - - Examples - -------- - >>> G = graphs.Ring(N=10) - >>> newW = G.W - >>> newW[1] = 1 - >>> G.update_graph_attr('N', 'd', W=newW) - - Updates all attributes of G except 'N' and 'd' - - """ - graph_attr = {} - valid_attributes = ['W', 'A', 'N', 'd', 'Ne', 'gtype', 'directed', - 'coords', 'lap_type', 'L', 'plotting'] - - for i in args: - if i in valid_attributes: - graph_attr[i] = getattr(self, i) - else: - self.logger.warning(('Your attribute {} does not figure in ' - 'the valid attributes, which are ' - '{}').format(i, valid_attributes)) - - for i in kwargs: - if i in valid_attributes: - if i in graph_attr: - self.logger.info('You already gave this attribute as ' - 'an argument. Therefore, it will not ' - 'be recomputed.') - else: - graph_attr[i] = kwargs[i] - else: - self.logger.warning(('Your attribute {} does not figure in ' - 'the valid attributes, which are ' - '{}').format(i, valid_attributes)) - - from pygsp.graphs import NNGraph - if isinstance(self, NNGraph): - super(NNGraph, self).__init__(**graph_attr) - else: - super(type(self), self).__init__(**graph_attr) - - def copy_graph_attributes(self, Gn, ctype=True): - r"""Copy some parameters of the graph into a given one. - - Parameters - ----------: - G : Graph structure - ctype : bool - Flag to select what to copy (Default is True) - Gn : Graph structure - The graph where the parameters will be copied - - Returns - ------- - Gn : Partial graph structure - - Examples - -------- - >>> Torus = graphs.Torus() - >>> G = graphs.TwoMoons() - >>> G.copy_graph_attributes(ctype=False, Gn=Torus); - - """ - if hasattr(self, 'plotting'): - Gn.plotting = self.plotting - - if ctype: - if hasattr(self, 'coords'): - Gn.coords = self.coords - else: - if hasattr(Gn.plotting, 'limits'): - del Gn.plotting['limits'] - - if hasattr(self, 'lap_type'): - Gn.compute_laplacian(self.lap_type) - # TODO: an existing Fourier basis should be updated - def set_coordinates(self, kind='spring', **kwargs): r"""Set node's coordinates (their position when plotting). diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 89388bfb..4b3ca11a 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -135,7 +135,7 @@ def graph_sparsify(M, epsilon, maxiter=10): sparserW = (sparserW + sparserW.T) / 2. Mnew = graphs.Graph(W=sparserW) - M.copy_graph_attributes(Mnew) + #M.copy_graph_attributes(Mnew) else: Mnew = sparse.lil_matrix(sparserL) @@ -713,7 +713,7 @@ def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', # Store new tree Gtemp = graphs.Graph(new_W, coords=Gs[lev].coords[keep_inds], limits=G.limits, gtype='tree', root=new_root) - Gs[lev].copy_graph_attributes(Gtemp, False) + #Gs[lev].copy_graph_attributes(Gtemp, False) if compute_full_eigen: Gs[lev + 1].compute_fourier_basis() From a9710a2513bbf41a91b235e840e0c0d7ce399d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 11:04:52 +0100 Subject: [PATCH 345/392] Default logging level shall not be configurable This change will help us detect when undefined arguments are passed to a graph constructor. --- pygsp/graphs/community.py | 2 +- pygsp/graphs/graph.py | 4 ++-- pygsp/graphs/nngraphs/grid2dimgpatches.py | 5 ++--- pygsp/graphs/randomregular.py | 2 +- pygsp/graphs/sensor.py | 2 +- pygsp/utils.py | 8 +++----- 6 files changed, 10 insertions(+), 13 deletions(-) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index 8727bffa..af9f586d 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -84,7 +84,7 @@ def __init__(self, epsilon = np.sqrt(2 * np.sqrt(N)) / 2 rs = np.random.RandomState(seed) - self.logger = utils.build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__) w_data = [[], [[], []]] if min_comm * Nc > N: diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 62e3c18c..102c63b1 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -64,9 +64,9 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): """ def __init__(self, W, gtype='unknown', lap_type='combinatorial', - coords=None, plotting={}, **kwargs): + coords=None, plotting={}): - self.logger = utils.build_logger(__name__, **kwargs) + 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)) diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 40f5b93e..ec0be42c 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -36,11 +36,10 @@ class Grid2dImgPatches(Graph): def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): - Gg = Grid2d(img.shape[0], img.shape[1], **kwargs) + Gg = Grid2d(img.shape[0], img.shape[1]) Gp = ImgPatches(img, patch_shape=patch_shape, n_nbrs=n_nbrs, **kwargs) gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), gtype=gtype, coords=Gg.coords, - plotting=Gg.plotting, - **kwargs) + plotting=Gg.plotting) diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index b56e715b..3cdd4a16 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -50,7 +50,7 @@ class RandomRegular(Graph): def __init__(self, N=64, k=6, maxIter=10, seed=None, **kwargs): self.k = k - self.logger = utils.build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__) rs = np.random.RandomState(seed) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 8d5b14a1..409c8814 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -46,7 +46,7 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, self.distribute = distribute self.seed = seed - self.logger = utils.build_logger(__name__, **kwargs) + self.logger = utils.build_logger(__name__) if connected: for x in range(self.n_try): diff --git a/pygsp/utils.py b/pygsp/utils.py index 53856dc4..44d7f9cf 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -20,20 +20,18 @@ import skimage -def build_logger(name, **kwargs): +def build_logger(name): logger = logging.getLogger(name) - logging_level = kwargs.pop('logging_level', logging.DEBUG) - if not logger.handlers: formatter = logging.Formatter( "%(asctime)s:[%(levelname)s](%(name)s.%(funcName)s): %(message)s") steam_handler = logging.StreamHandler() - steam_handler.setLevel(logging_level) + steam_handler.setLevel(logging.DEBUG) steam_handler.setFormatter(formatter) - logger.setLevel(logging_level) + logger.setLevel(logging.DEBUG) logger.addHandler(steam_handler) return logger From 7f6fb0e7b6ce6f3d36dacba82245453f2dcfdf8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 10:32:11 +0100 Subject: [PATCH 346/392] remove skimage: skimage.util.pad is np.pad --- pygsp/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index 44d7f9cf..b506a808 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -460,7 +460,7 @@ def extract_patches(img, patch_shape=(3, 3)): (0, 0)) window_shape = (r, c, d) # Pad the image - img_pad = skimage.util.pad(img, pad_width=pad_width, mode='symmetric') + img_pad = np.pad(img, pad_width=pad_width, mode='symmetric') # Extract patches patches = skimage.util.view_as_windows(img_pad, window_shape=window_shape) From 62bbafbc64244e5fa673e0b3206e4eed2f154fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 13:58:27 +0100 Subject: [PATCH 347/392] fix constructors for imgpatches & grid2dimgpatches --- pygsp/graphs/nngraphs/grid2dimgpatches.py | 23 ++++++++++------------- pygsp/graphs/nngraphs/imgpatches.py | 21 ++++++--------------- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index ec0be42c..56b895d5 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -11,34 +11,31 @@ class Grid2dImgPatches(Graph): ---------- img : array Input image. - patch_shape : tuple, optional - Dimensions of the patch window. Syntax : (height, width). - n_nbrs : int - Number of neighbors to consider - dist_type : string - Type of distance between patches to compute. See - :func:`pyflann.index.set_distance_type` for possible options. aggregate: callable, optional - Function used for aggregating the weights Wp of the patch graph and the - weigths Wg 2d grid graph. Default is :func:`lambda Wp, Wg: Wp + Wg`. + Function to aggregate the weights ``Wp`` of the patch graph and the + ``Wg`` of the grid graph. Default is ``lambda Wp, Wg: Wp + Wg``. + kwargs : dict + Parameters passed to :class:`ImgPatches`. Examples -------- >>> import matplotlib.pyplot as plt >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::64, ::64]) - >>> G = graphs.Grid2dImgPatches(img, use_flann=False) + >>> G = graphs.Grid2dImgPatches(img) >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=2) >>> G.plot(ax=axes[1]) """ - def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, - aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): + def __init__(self, img, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): + Gg = Grid2d(img.shape[0], img.shape[1]) - Gp = ImgPatches(img, patch_shape=patch_shape, n_nbrs=n_nbrs, **kwargs) + Gp = ImgPatches(img, **kwargs) + gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) + super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), gtype=gtype, coords=Gg.coords, diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index f0193eeb..4fcd88c0 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -14,18 +14,15 @@ class ImgPatches(NNGraph): patch_shape : tuple, optional Dimensions of the patch window. Syntax: (height, width), or (height,), in which case width = height. - n_nbrs : int, optional - Number of neighbors to consider - dist_type : string, optional - Type of distance between patches to compute. See - :func:`pyflann.index.set_distance_type` for possible options. + kwargs : dict + Parameters passed to :class:`NNGraph`. Examples -------- >>> import matplotlib.pyplot as plt >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::64, ::64]) - >>> G = graphs.ImgPatches(img, use_flann=False) + >>> G = graphs.ImgPatches(img) >>> G.set_coordinates(kind='spring', seed=42) >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=2) @@ -33,15 +30,9 @@ class ImgPatches(NNGraph): """ - def __init__(self, img, patch_shape=(3, 3), n_nbrs=8, use_flann=True, - dist_type='euclidean', symmetrize_type='fill', **kwargs): + def __init__(self, img, patch_shape=(3, 3), **kwargs): + self.img = img X = utils.extract_patches(img, patch_shape=patch_shape) - super(ImgPatches, self).__init__(X, - use_flann=use_flann, - symmetrize_type=symmetrize_type, - dist_type=dist_type, - gtype='patch-graph', - **kwargs) - self.img = img + super(ImgPatches, self).__init__(X, gtype='patch-graph', **kwargs) From f4b8a2a34902172f9502c7c91b3becd3f8b98858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 15:45:15 +0100 Subject: [PATCH 348/392] imgpatches: integrate utils.extract_patches (sole user) --- pygsp/graphs/nngraphs/imgpatches.py | 58 +++++++++++++++++++++++-- pygsp/utils.py | 67 ----------------------------- 2 files changed, 54 insertions(+), 71 deletions(-) diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index 4fcd88c0..a8d1be4c 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,12 +1,18 @@ # -*- coding: utf-8 -*- -from pygsp import utils +import numpy as np +import skimage + from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 class ImgPatches(NNGraph): r"""NN-graph between patches of an image. + 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`. + Parameters ---------- img : array @@ -17,12 +23,23 @@ class ImgPatches(NNGraph): kwargs : dict Parameters passed to :class:`NNGraph`. + Notes + ----- + The feature vector of a pixel `i` will consist of the stacking of the + intensity values of all pixels in the patch centered at `i`, for all color + channels. So, if the input image has `d` color channels, the dimension of + the feature vector of each pixel is (patch_shape[0] * patch_shape[1] * d). + Examples -------- >>> import matplotlib.pyplot as plt >>> from skimage import data, img_as_float >>> img = img_as_float(data.camera()[::64, ::64]) - >>> G = graphs.ImgPatches(img) + >>> G = graphs.ImgPatches(img, patch_shape=(3, 3)) + >>> print('{} nodes ({} x {} pixels)'.format(G.Xin.shape[0], *img.shape)) + 64 nodes (8 x 8 pixels) + >>> print('{} features per node'.format(G.Xin.shape[1])) + 9 features per node >>> G.set_coordinates(kind='spring', seed=42) >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=2) @@ -33,6 +50,39 @@ class ImgPatches(NNGraph): def __init__(self, img, patch_shape=(3, 3), **kwargs): self.img = img - X = utils.extract_patches(img, patch_shape=patch_shape) - super(ImgPatches, self).__init__(X, gtype='patch-graph', **kwargs) + try: + h, w, d = img.shape + except ValueError: + try: + h, w = img.shape + d = 0 + except ValueError: + print("Image should be at least a 2D array.") + + try: + r, c = patch_shape + except ValueError: + r = patch_shape[0] + c = r + + pad_width = [(int((r - 0.5) / 2.), int((r + 0.5) / 2.)), + (int((c - 0.5) / 2.), int((c + 0.5) / 2.))] + + if d == 0: + window_shape = (r, c) + d = 1 # For the reshape in the return call + else: + pad_width += [(0, 0)] + window_shape = (r, c, d) + + # Pad the image. + img = np.pad(img, pad_width=pad_width, mode='symmetric') + + # Extract patches as node features. + patches = skimage.util.view_as_windows(img, window_shape=window_shape) + patches = patches.reshape((h * w, r * c * d)) + + super(ImgPatches, self).__init__(patches, + gtype='patch-graph', + **kwargs) diff --git a/pygsp/utils.py b/pygsp/utils.py index b506a808..eaab3473 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -17,7 +17,6 @@ import numpy as np from scipy import sparse import scipy.io -import skimage def build_logger(name): @@ -402,72 +401,6 @@ def repmatline(A, ncol=1, nrow=1): return np.repeat(np.repeat(A, ncol, axis=1), nrow, axis=0) -def extract_patches(img, patch_shape=(3, 3)): - r""" - Extract a patch feature vector for every pixel of an image. - - Parameters - ---------- - img : array - Input image. - patch_shape : tuple, optional - Dimensions of the patch window. Syntax: (height, width), or (height,), - in which case width = height. - - Returns - ------- - array - Feature matrix. - - Notes - ----- - The feature vector of a pixel `i` will consist of the stacking of the - intensity values of all pixels in the patch centered at `i`, for all color - channels. So, if the input image has `d` color channels, the dimension of - the feature vector of each pixel is (patch_shape[0] * patch_shape[1] * d). - - Examples - -------- - >>> from pygsp import utils - >>> import skimage - >>> img = skimage.img_as_float(skimage.data.camera()[::2, ::2]) - >>> X = utils.extract_patches(img) - - """ - - try: - h, w, d = img.shape - except ValueError: - try: - h, w = img.shape - d = 0 - except ValueError: - print("Image should be at least a 2-d array.") - - try: - r, c = patch_shape - except ValueError: - r = patch_shape[0] - c = r - if d == 0: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.))) - window_shape = (r, c) - d = 1 # For the reshape in the return call - else: - pad_width = ((int((r - 0.5) / 2.), int((r + 0.5) / 2.)), - (int((c - 0.5) / 2.), int((c + 0.5) / 2.)), - (0, 0)) - window_shape = (r, c, d) - # Pad the image - img_pad = np.pad(img, pad_width=pad_width, mode='symmetric') - - # Extract patches - patches = skimage.util.view_as_windows(img_pad, window_shape=window_shape) - - return patches.reshape((h * w, r * c * d)) - - def import_modules(names, src, dst): """Import modules in package.""" for name in names: From d9db57709a0fb7ee37a1c8b3c58e6f861877af87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 15:52:40 +0100 Subject: [PATCH 349/392] scikit-image as an optional dependancy --- .travis.yml | 2 +- CONTRIBUTING.rst | 2 +- pygsp/graphs/nngraphs/imgpatches.py | 9 ++++++++- setup.py | 9 ++++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8bc35b0f..835c6d85 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ addons: install: - pip install -U pip setuptools - - pip install -U .[test,doc] + - pip install -U .[alldeps,test,doc] script: # - make lint diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 07516702..b112891c 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[test,doc,pkg] + $ pip install -U -e pygsp[alldeps,test,doc,pkg] 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/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index a8d1be4c..ecdb33ba 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import numpy as np -import skimage from pygsp.graphs import NNGraph # prevent circular import in Python < 3.5 @@ -80,6 +79,14 @@ def __init__(self, img, patch_shape=(3, 3), **kwargs): img = np.pad(img, pad_width=pad_width, mode='symmetric') # Extract patches as node features. + # Alternative: sklearn.feature_extraction.image.extract_patches_2d. + # sklearn has much less dependencies than skimage. + try: + import skimage + except Exception: + raise ImportError('Cannot import skimage, which is needed to ' + 'extract patches. Try to install it with ' + 'pip (or conda) install scikit-image.') patches = skimage.util.view_as_windows(img, window_shape=window_shape) patches = patches.reshape((h * w, r * c * d)) diff --git a/setup.py b/setup.py index a36ff274..a6dd98b3 100644 --- a/setup.py +++ b/setup.py @@ -30,17 +30,23 @@ # No source package for PyQt5 on PyPI, fall back to PySide. 'PySide; python_version < "3.5"', 'pyopengl', - 'scikit-image', 'pyflann; python_version == "2.*"', 'pyflann3; python_version == "3.*"', ], extras_require={ + # Optional dependencies for some functionalities. + 'alldeps': ( + # Construct patch graphs from images. + 'scikit-image', + ), + # Testing dependencies. 'test': [ 'pyunlocbox', 'flake8', 'coverage', 'coveralls', ], + # Dependencies to build the documentation. 'doc': [ 'pyunlocbox', 'sphinx', @@ -48,6 +54,7 @@ 'sphinxcontrib-bibtex', 'sphinx-rtd-theme', ], + # Dependencies to build and upload packages. 'pkg': [ 'wheel', 'twine', From 066fb4fe5868134cbacd53030c864cc45e256682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 18:22:11 +0100 Subject: [PATCH 350/392] matplotlib & pyqtgraph as optional dependencies --- pygsp/plotting.py | 75 +++++++++++++++++++++++++++-------------------- setup.py | 16 +++++----- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 1baddb4d..98c628d0 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -21,34 +21,12 @@ from __future__ import division -import traceback - import numpy as np from pygsp import utils -_logger = utils.build_logger(__name__) - -try: - import matplotlib.pyplot as plt - # Not used directly, but needed for 3D projection. - from mpl_toolkits.mplot3d import Axes3D # noqa - _PLT_IMPORT = True -except Exception: - _logger.error('Cannot import packages for matplotlib: {}'.format( - traceback.format_exc())) - _PLT_IMPORT = False - -try: - import pyqtgraph as qtg - import pyqtgraph.opengl as gl - from pyqtgraph.Qt import QtGui - _QTG_IMPORT = True -except Exception: - _logger.error('Cannot import packages for pyqtgraph: {}'.format( - traceback.format_exc())) - _QTG_IMPORT = False +_logger = utils.build_logger(__name__) BACKEND = 'pyqtgraph' _qtg_windows = [] @@ -56,10 +34,37 @@ _plt_figures = [] +def _import_plt(): + try: + import matplotlib.pyplot as plt + # Not used directly, but needed for 3D projection. + from mpl_toolkits.mplot3d import Axes3D # noqa + except Exception: + raise ImportError('Cannot import matplotlib. Choose another backend ' + 'or try to install it with ' + 'pip (or conda) install matplotlib.') + return plt + + +def _import_qtg(): + try: + import pyqtgraph as qtg + import pyqtgraph.opengl as gl + from pyqtgraph.Qt import QtGui + except Exception: + raise ImportError('Cannot import pyqtgraph. Choose another backend ' + 'or try to install it with ' + 'pip (or conda) install pyqtgraph. You will also ' + 'need PyQt5 (or PySide) and pyopengl.') + return qtg, gl, QtGui + + def _plt_handle_figure(plot): def inner(obj, *args, **kwargs): + plt = _import_plt() + # Create a figure and an axis if none were passed. if 'ax' not in kwargs.keys(): fig = plt.figure() @@ -114,6 +119,7 @@ def close_all(): global _plt_figures for fig in _plt_figures: + plt = _import_plt() plt.close(fig) _plt_figures = [] @@ -126,6 +132,7 @@ def show(*args, **kwargs): By default, showing plots does not block the prompt. """ + plt = _import_plt() plt.show(*args, **kwargs) @@ -136,6 +143,7 @@ def close(*args, **kwargs): Alias to plt.close(). """ + plt = _import_plt() plt.close(*args, **kwargs) @@ -217,12 +225,12 @@ def plot_graph(G, backend=None, **kwargs): G = _handle_directed(G) - if backend == 'pyqtgraph' and _QTG_IMPORT: + if backend == 'pyqtgraph': _qtg_plot_graph(G, **kwargs) - elif backend == 'matplotlib' and _PLT_IMPORT: + elif backend == 'matplotlib': _plt_plot_graph(G, **kwargs) else: - raise ValueError('The {} backend is not available.'.format(backend)) + raise ValueError('Unknown backend {}.'.format(backend)) @_plt_handle_figure @@ -285,6 +293,8 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): # TODO handling when G is a list of graphs + qtg, gl, QtGui = _import_qtg() + if G.is_directed(): raise NotImplementedError @@ -488,12 +498,12 @@ def plot_signal(G, signal, backend=None, **kwargs): G = _handle_directed(G) - if backend == 'pyqtgraph' and _QTG_IMPORT: + if backend == 'pyqtgraph': _qtg_plot_signal(G, signal, **kwargs) - elif backend == 'matplotlib' and _PLT_IMPORT: + elif backend == 'matplotlib': _plt_plot_signal(G, signal, **kwargs) else: - raise ValueError('The {} backend is not available.'.format(backend)) + raise ValueError('Unknown backend {}.'.format(backend)) @_plt_handle_figure @@ -562,11 +572,14 @@ def _plt_plot_signal(G, signal, show_edges, limits, ax, pass if G.coords.ndim != 1 and colorbar: + plt = _import_plt() plt.colorbar(sc, ax=ax) def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): + qtg, gl, QtGui = _import_qtg() + if G.coords.shape[1] == 2: window = qtg.GraphicsWindow(plot_name) view = window.addViewBox() @@ -648,9 +661,7 @@ def plot_spectrogram(G, node_idx=None): """ from pygsp import features - if not _QTG_IMPORT: - raise NotImplementedError('You need pyqtgraph to plot the spectrogram ' - 'at the moment. Please install and retry.') + qtg, _, _ = _import_qtg() if not hasattr(G, 'spectr'): features.compute_spectrogram(G) diff --git a/setup.py b/setup.py index a6dd98b3..8ea98be9 100644 --- a/setup.py +++ b/setup.py @@ -23,13 +23,6 @@ install_requires=[ 'numpy', 'scipy', - 'matplotlib', - 'pyqtgraph', - # PyQt5 is only available on PyPI as wheels for Python 3.5 and up. - 'PyQt5; python_version >= "3.5"', - # No source package for PyQt5 on PyPI, fall back to PySide. - 'PySide; python_version < "3.5"', - 'pyopengl', 'pyflann; python_version == "2.*"', 'pyflann3; python_version == "3.*"', ], @@ -38,6 +31,15 @@ 'alldeps': ( # Construct patch graphs from images. 'scikit-image', + # Plot graphs, signals, and filters. + 'matplotlib', + # Interactive graph visualization. + 'pyqtgraph', + 'pyopengl', + # PyQt5 is only available on PyPI as wheels for Python 3.5 and up. + 'PyQt5; python_version >= "3.5"', + # No source package for PyQt5 on PyPI, fall back to PySide. + 'PySide; python_version < "3.5"', ), # Testing dependencies. 'test': [ From 5113e3253ef0323b49c8db91d8933bc5da5a8bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 18:37:37 +0100 Subject: [PATCH 351/392] pyflann as an optional dependancy --- pygsp/graphs/nngraphs/nngraph.py | 19 +++++++++++-------- setup.py | 5 +++-- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 8e4b6f44..397af4e7 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -10,13 +10,15 @@ _logger = utils.build_logger(__name__) -try: - import pyflann as pfl - _PFL_IMPORT = True -except Exception: - _logger.warning('Cannot import pyflann (used for faster kNN computations):' - ' {}'.format(traceback.format_exc())) - _PFL_IMPORT = False + +def _import_pfl(): + try: + import pyflann as pfl + except Exception: + raise ImportError('Cannot import pyflann. Choose another nearest ' + 'neighbors method or try to install it with ' + 'pip (or conda) install pyflann (or pyflann3).') + return pfl class NNGraph(Graph): @@ -118,7 +120,8 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, spj = np.zeros((N * k)) spv = np.zeros((N * k)) - if self.use_flann and _PFL_IMPORT: + if self.use_flann: + pfl = _import_pfl() pfl.set_distance_type(dist_type, order=order) flann = pfl.FLANN() diff --git a/setup.py b/setup.py index 8ea98be9..9e385967 100644 --- a/setup.py +++ b/setup.py @@ -23,14 +23,15 @@ install_requires=[ 'numpy', 'scipy', - 'pyflann; python_version == "2.*"', - 'pyflann3; python_version == "3.*"', ], extras_require={ # Optional dependencies for some functionalities. 'alldeps': ( # Construct patch graphs from images. 'scikit-image', + # Approximate nearest neighbors for kNN graphs. + 'pyflann; python_version == "2.*"', + 'pyflann3; python_version == "3.*"', # Plot graphs, signals, and filters. 'matplotlib', # Interactive graph visualization. From 4d4e51a55f237e42a8b51ef81875b4c8f4280701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 18:40:13 +0100 Subject: [PATCH 352/392] correct case for PyPI: pyopengl --> PyOpenGL --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9e385967..26dc6499 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,7 @@ 'matplotlib', # Interactive graph visualization. 'pyqtgraph', - 'pyopengl', + 'PyOpenGL', # PyQt5 is only available on PyPI as wheels for Python 3.5 and up. 'PyQt5; python_version >= "3.5"', # No source package for PyQt5 on PyPI, fall back to PySide. From 730b4f027c8bddc1170c97c20923dbd8a2915a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 18:54:42 +0100 Subject: [PATCH 353/392] matplotlib as default plotting backend --- doc/conf.py | 1 - doc/tutorials/optimization.rst | 1 - doc/tutorials/wavelet.rst | 1 - pygsp/plotting.py | 2 +- pygsp/tests/test_all.py | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index e9b45753..a7adb9ca 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -32,7 +32,6 @@ plot_pre_code = """ import numpy as np from pygsp import graphs, filters, utils, plotting -plotting.BACKEND = 'matplotlib' """ exclude_patterns = ['_build'] diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index 41bd729a..ef3d4f82 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -14,7 +14,6 @@ This tutorial focuses on the problem of recovering a label signal on a graph fro >>> import numpy as np >>> from pygsp import graphs, plotting - >>> plotting.BACKEND = 'matplotlib' >>> >>> # Create a random sensor graph >>> G = graphs.Sensor(N=256, distribute=True, seed=42) diff --git a/doc/tutorials/wavelet.rst b/doc/tutorials/wavelet.rst index 375dd76b..86b5dcd1 100644 --- a/doc/tutorials/wavelet.rst +++ b/doc/tutorials/wavelet.rst @@ -17,7 +17,6 @@ As usual, we first have to import some packages. >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from pygsp import graphs, filters, plotting, utils - >>> plotting.BACKEND = 'matplotlib' Then we can load a graph. The graph we'll use is a nearest-neighbor graph of a point cloud of the Stanford bunny. It will allow us to get interesting visual diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 98c628d0..38737d5a 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -28,7 +28,7 @@ _logger = utils.build_logger(__name__) -BACKEND = 'pyqtgraph' +BACKEND = 'matplotlib' _qtg_windows = [] _qtg_widgets = [] _plt_figures = [] diff --git a/pygsp/tests/test_all.py b/pygsp/tests/test_all.py index 5e098e15..e6b675b5 100755 --- a/pygsp/tests/test_all.py +++ b/pygsp/tests/test_all.py @@ -29,7 +29,6 @@ def test_docstrings(root, ext, setup=None): def setup(doctest): import numpy import pygsp - pygsp.plotting.BACKEND = 'matplotlib' doctest.globs = { 'graphs': pygsp.graphs, 'filters': pygsp.filters, From ba536bd8b0da260705af2afb4936db363448a91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 13 Dec 2017 20:29:56 +0100 Subject: [PATCH 354/392] update history --- doc/history.rst | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/doc/history.rst b/doc/history.rst index 86d15775..bd0bb24a 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -2,6 +2,39 @@ History ======= +0.5.1 (2017-12-xx) +------------------ + +The focus of this release was to ease installation by not requiring +non-standard scientific Python packages to be installed. + +The core functionality of this package only depends on numpy and scipy. +Dependencies which are only required for particular usages are included in the +alldeps extra dependency list. The alldeps list allows users to install +dependencies to enable all the features. Finally, those optional packages are +only loaded when needed, not when the PyGSP is imported. A nice side-effect is +that importing the PyGSP is now much faster! + +The following packages were made optional dependencies: +* scikit-image, as it is only used to build patch graphs from images. The + problem was that scikit-image does not provide a wheel for Windows and its + build is painful and error-prone. Moreover, scikit-image has a lot of + dependencies. +* pyqtgrpah, PyQt5 / PySide and pyopengl, as they are only used for interactive + visualization, which not many users need. The problem was that pyqtgraph + requires (via PyQt5, PySide, pyopengl) OpenGL (libGL.so) to be installed. +* matplotlib: while it is a standard package for any scientific or data science + 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 + is not built for Windows either. + +Moreover, matplotlib is now the default drawing backend. It's well integrated +with the Jupyter environment for scientific and data science workflows, and +most use cases do not require an interactive visualization. The pyqtgraph is +still available for interactivity. + 0.5.0 (2017-10-06) ------------------ From d861a7b5c27a6c11ea38be9e3025ede5b151ece5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Dec 2017 14:11:22 +0100 Subject: [PATCH 355/392] pyunlocbox as an optional dependency --- pygsp/optimization.py | 13 ++++++++++++- setup.py | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index 1b61f407..658673f1 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -11,6 +11,16 @@ logger = utils.build_logger(__name__) +def _import_pyunlocbox(): + try: + from pyunlocbox import functions, solvers + except Exception: + raise ImportError('Cannot import pyunlocbox, which is needed to solve ' + 'this optimization problem. Try to install it with ' + 'pip (or conda) install pyunlocbox.') + return functions, solvers + + def prox_tv(x, gamma, G, A=None, At=None, nu=1, tol=10e-4, maxit=200, use_matrix=True): r""" Total Variation proximal operator for graphs. @@ -81,4 +91,5 @@ def l1_a(x): def l1_at(x): return G.div(x) - pyunlocbox.prox_l1(x, gamma, A=l1_a, At=l1_at, tight=tight, maxit=maxit, verbose=verbose, tol=tol) + functions, _ = _import_pyunlocbox() + functions.norm_l1(x, gamma, A=l1_a, At=l1_at, tight=tight, maxit=maxit, verbose=verbose, tol=tol) diff --git a/setup.py b/setup.py index 26dc6499..5b655cbe 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,8 @@ # Approximate nearest neighbors for kNN graphs. 'pyflann; python_version == "2.*"', 'pyflann3; python_version == "3.*"', + # Convex optimization on graph. + 'pyunlocbox', # Plot graphs, signals, and filters. 'matplotlib', # Interactive graph visualization. @@ -44,14 +46,12 @@ ), # Testing dependencies. 'test': [ - 'pyunlocbox', 'flake8', 'coverage', 'coveralls', ], # Dependencies to build the documentation. 'doc': [ - 'pyunlocbox', 'sphinx', 'numpydoc', 'sphinxcontrib-bibtex', From ece2e4b411d02ac6f73ecc93888adc865a4afc48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Dec 2017 17:05:29 +0100 Subject: [PATCH 356/392] include license in sdist, required by conda-forge --- MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..97e2ad3d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE.txt +include README.rst From e9c5995eda0d1ff89200e60a9e0151c1452e1521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 14 Dec 2017 18:47:17 +0100 Subject: [PATCH 357/392] readme: arrange badges with a table (looks better on RTD) --- README.rst | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index 9e1f4f40..381a8757 100644 --- a/README.rst +++ b/README.rst @@ -2,42 +2,38 @@ PyGSP: Graph Signal Processing in Python ======================================== -.. image:: https://readthedocs.org/projects/pygsp/badge/?version=latest - :target: https://pygsp.readthedocs.io ++--------------------------------------------------+ +| |doc| |pypi| |zenodo| |license| |pyversions| | ++--------------------------------------------------+ +| |travis| |coveralls| |github| | ++--------------------------------------------------+ -.. image:: https://img.shields.io/pypi/v/pygsp.svg +.. |doc| image:: https://readthedocs.org/projects/pygsp/badge/?version=latest + :target: https://pygsp.readthedocs.io +.. |pypi| image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP - -.. image:: https://zenodo.org/badge/16276560.svg +.. |zenodo| image:: https://zenodo.org/badge/16276560.svg :target: https://doi.org/10.5281/zenodo.1003157 - -.. image:: https://img.shields.io/pypi/l/pygsp.svg +.. |license| image:: https://img.shields.io/pypi/l/pygsp.svg :target: https://github.com/epfl-lts2/pygsp/blob/master/LICENSE.txt - -.. image:: https://img.shields.io/pypi/pyversions/pygsp.svg +.. |pyversions| image:: https://img.shields.io/pypi/pyversions/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP - -| - -.. image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg +.. |travis| image:: https://img.shields.io/travis/epfl-lts2/pygsp.svg :target: https://travis-ci.org/epfl-lts2/pygsp - -.. image:: https://img.shields.io/coveralls/epfl-lts2/pygsp.svg +.. |coveralls| image:: https://img.shields.io/coveralls/epfl-lts2/pygsp.svg :target: https://coveralls.io/github/epfl-lts2/pygsp - -.. image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social +.. |github| image:: https://img.shields.io/github/stars/epfl-lts2/pygsp.svg?style=social :target: https://github.com/epfl-lts2/pygsp -| - -The PyGSP is a Python package to ease `Signal Processing on Graphs -`_ -(a `Matlab counterpart `_ -exists). It is a free software, distributed under the BSD license, and -available on `PyPI `_. The -documentation is available on `Read the Docs -`_ and development takes place on `GitHub -`_. +The PyGSP is a Python package to ease +`Signal Processing on Graphs `_. +It is a free software, distributed under the BSD license, and +available on `PyPI `_. +The documentation is available on +`Read the Docs `_ +and development takes place on +`GitHub `_. +(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, From d8cf34758cc3e19a587e14cdfff8e152f859e218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 00:59:08 +0100 Subject: [PATCH 358/392] typo --- pygsp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 3fac4174..4434a670 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -4,7 +4,7 @@ The :mod:`pygsp` package is mainly organized around the following two modules: * :mod:`.graphs` to create and manipulate various kinds of graphs, -* :mod:`.filters` to create and manipulate various graph filters, +* :mod:`.filters` to create and manipulate various graph filters. Moreover, the following modules provide additional functionality: From dfc624f817f19049682389f8a857cd1ac404aea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 01:58:35 +0100 Subject: [PATCH 359/392] RTD: matplotlib 2 is now installed by default with pip --- .environment.yml | 18 ------------------ .readthedocs.yml | 5 ++--- 2 files changed, 2 insertions(+), 21 deletions(-) delete mode 100644 .environment.yml diff --git a/.environment.yml b/.environment.yml deleted file mode 100644 index ccbeb36e..00000000 --- a/.environment.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: read-the-docs - -channels: - - conda-forge - - defaults -# Cannot do without defaults because of libgfortran. -# https://github.com/conda-forge/libgfortran-feedstock/issues/9 - -dependencies: - - python=3 - - numpy - - scipy - - matplotlib - - scikit-image - - sphinx - - numpydoc - - sphinxcontrib-bibtex - - sphinx_rtd_theme diff --git a/.readthedocs.yml b/.readthedocs.yml index 2fc18eae..c6869239 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,9 +2,8 @@ formats: - htmlzip python: + version: 3 pip_install: true extra_requirements: + - alldeps - doc - -conda: - file: .environment.yml From 4c6eb747da39757f088efdaaf7ebf3e9e00f630f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 02:46:36 +0100 Subject: [PATCH 360/392] typo: pyopengl --> PyOpenGL --- doc/history.rst | 4 ++-- pygsp/plotting.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index bd0bb24a..69d16e9d 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -20,9 +20,9 @@ The following packages were made optional dependencies: problem was that scikit-image does not provide a wheel for Windows and its build is painful and error-prone. Moreover, scikit-image has a lot of dependencies. -* pyqtgrpah, PyQt5 / PySide and pyopengl, as they are only used for interactive +* pyqtgrpah, PyQt5 / PySide and PyOpenGl, as they are only used for interactive visualization, which not many users need. The problem was that pyqtgraph - requires (via PyQt5, PySide, pyopengl) OpenGL (libGL.so) to be installed. + requires (via PyQt5, PySide, PyOpenGL) OpenGL (libGL.so) to be installed. * matplotlib: while it is a standard package for any scientific or data science workflow, it's not necessary for users who only want to process data without plotting graphs, signals and filters. diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 38737d5a..c6fde8aa 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -55,7 +55,7 @@ def _import_qtg(): raise ImportError('Cannot import pyqtgraph. Choose another backend ' 'or try to install it with ' 'pip (or conda) install pyqtgraph. You will also ' - 'need PyQt5 (or PySide) and pyopengl.') + 'need PyQt5 (or PySide) and PyOpenGL.') return qtg, gl, QtGui From 7c64f09dcd136e5872ef60ef347e7a682af2534d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 02:29:42 +0100 Subject: [PATCH 361/392] experiment online with binder --- README.rst | 13 +++-- playground.ipynb | 122 +++++++++++++++++++++++++++++++++++++++++++++++ postBuild | 3 ++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 playground.ipynb create mode 100755 postBuild diff --git a/README.rst b/README.rst index 381a8757..c1b313dc 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,7 @@ PyGSP: Graph Signal Processing in Python +--------------------------------------------------+ | |doc| |pypi| |zenodo| |license| |pyversions| | +--------------------------------------------------+ -| |travis| |coveralls| |github| | +| |binder| |travis| |coveralls| |github| | +--------------------------------------------------+ .. |doc| image:: https://readthedocs.org/projects/pygsp/badge/?version=latest @@ -24,6 +24,8 @@ PyGSP: Graph Signal Processing in Python :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 + :target: https://mybinder.org/v2/gh/epfl-lts2/pygsp/master?filepath=playground.ipynb The PyGSP is a Python package to ease `Signal Processing on Graphs `_. @@ -74,8 +76,13 @@ structure! .. image:: pygsp/data/readme_example.png :alt: -Please see the tutorials for more usage examples and the reference guide for an -exhaustive documentation of the API. Enjoy the package! +You can +`try it online `_, +look at the +`tutorials `_ +to learn how to use it, or look at the +`reference guide `_ +for an exhaustive documentation of the API. Enjoy the package! Installation ------------ diff --git a/playground.ipynb b/playground.ipynb new file mode 100644 index 00000000..f2d16995 --- /dev/null +++ b/playground.ipynb @@ -0,0 +1,122 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Playing with the PyGSP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from pygsp import graphs, filters\n", + "\n", + "plt.rcParams['figure.figsize'] = (17, 5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1 Example\n", + "\n", + "The following demonstrates how to instantiate a graph and a filter, the two main objects of the package." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G = graphs.Logo()\n", + "G.estimate_lmax()\n", + "g = filters.Heat(G, tau=100)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's now create a graph signal: a set of three Kronecker deltas for that example. We can now look at one step of heat diffusion by filtering the deltas with the above defined filter. Note how the diffusion follows the local structure!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "DELTAS = [20, 30, 1090]\n", + "s = np.zeros(G.N)\n", + "s[DELTAS] = 1\n", + "s = g.filter(s)\n", + "G.plot_signal(s, highlight=DELTAS, backend='matplotlib')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2 Tutorials\n", + "\n", + "Try to follow one of our [tutorials](https://pygsp.readthedocs.io/en/stable/tutorials/index.html)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Your code here." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3 Playground\n", + "\n", + "Or try anything that goes through your mind!\n", + "Look at the [reference guide](https://pygsp.readthedocs.io/en/stable/reference/index.html) to know more about the API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Your code here." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you miss a package, you can install it with:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! pip install pygsp" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/postBuild b/postBuild new file mode 100755 index 00000000..6eb53a4d --- /dev/null +++ b/postBuild @@ -0,0 +1,3 @@ +#!/bin/bash +# Tell https://mybinder.org to simply install the package. +pip install .[alldeps] From 81ab83ec75a43ef5fe8a7222f1ccfce38294cf73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 11:12:15 +0100 Subject: [PATCH 362/392] playground: link to repo --- playground.ipynb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/playground.ipynb b/playground.ipynb index f2d16995..5fc4811a 100644 --- a/playground.ipynb +++ b/playground.ipynb @@ -4,7 +4,8 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Playing with the PyGSP" + "# Playing with the PyGSP\n", + "" ] }, { From b7ca37c0f0eac7bb34167b6a7be0d2d5dbde7662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 13:57:42 +0100 Subject: [PATCH 363/392] version 0.5.1 --- doc/history.rst | 4 +++- pygsp/__init__.py | 4 ++-- setup.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/history.rst b/doc/history.rst index 69d16e9d..5c28e86a 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -2,11 +2,13 @@ History ======= -0.5.1 (2017-12-xx) +0.5.1 (2017-12-15) ------------------ The focus of this release was to ease installation by not requiring non-standard scientific Python packages to be installed. +It was mostly a maintenance release. A conda package is now available in +conda-forge. Moreover, the package can now be tried online thanks to binder. The core functionality of this package only depends on numpy and scipy. Dependencies which are only required for particular usages are included in the diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 4434a670..15ef39c1 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -30,5 +30,5 @@ _utils.import_modules(__all__[::-1], 'pygsp', 'pygsp') -__version__ = '0.5.0' -__release_date__ = '2017-10-06' +__version__ = '0.5.1' +__release_date__ = '2017-12-15' diff --git a/setup.py b/setup.py index 5b655cbe..bcf0ccd9 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='PyGSP', - version='0.5.0', + version='0.5.1', description='Graph Signal Processing in Python', long_description=open('README.rst').read(), author='EPFL LTS2', From 2c16033258dac308413ee54439b322a696333531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 15 Dec 2017 17:33:27 +0100 Subject: [PATCH 364/392] doc: fix itersine example (issue #18) --- pygsp/filters/itersine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 2db0b5e6..38f558d1 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -28,7 +28,7 @@ class Itersine(Filter): >>> G = graphs.Ring(N=20) >>> G.estimate_lmax() >>> G.set_coordinates('line1D') - >>> g = filters.HalfCosine(G) + >>> g = filters.Itersine(G) >>> s = g.localize(G.N // 2) >>> fig, axes = plt.subplots(1, 2) >>> g.plot(ax=axes[0]) From 007feb0bc059364e51eeb36735bafa8d6f0570e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 16 Dec 2017 16:36:31 +0100 Subject: [PATCH 365/392] available on conda-forge --- README.rst | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index c1b313dc..2d6c59d4 100644 --- a/README.rst +++ b/README.rst @@ -2,11 +2,13 @@ PyGSP: Graph Signal Processing in Python ======================================== -+--------------------------------------------------+ -| |doc| |pypi| |zenodo| |license| |pyversions| | -+--------------------------------------------------+ -| |binder| |travis| |coveralls| |github| | -+--------------------------------------------------+ ++-----------------------------------+ +| |doc| |pypi| |conda| |binder| | ++-----------------------------------+ +| |zenodo| |license| |pyversions| | ++-----------------------------------+ +| |travis| |coveralls| |github| | ++-----------------------------------+ .. |doc| image:: https://readthedocs.org/projects/pygsp/badge/?version=latest :target: https://pygsp.readthedocs.io @@ -26,6 +28,8 @@ PyGSP: Graph Signal Processing in Python :target: https://github.com/epfl-lts2/pygsp .. |binder| image:: https://mybinder.org/badge.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 The PyGSP is a Python package to ease `Signal Processing on Graphs `_. @@ -94,6 +98,10 @@ The PyGSP is available on PyPI:: 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 + Contributing ------------ From 6f9be537af045dc1fc8bae7f3cfbaf3c6ddbdd4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 17 Dec 2017 13:34:42 +0100 Subject: [PATCH 366/392] playground: link to NTDS GSP demo --- playground.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground.ipynb b/playground.ipynb index 5fc4811a..c530a12a 100644 --- a/playground.ipynb +++ b/playground.ipynb @@ -69,7 +69,7 @@ "source": [ "## 2 Tutorials\n", "\n", - "Try to follow one of our [tutorials](https://pygsp.readthedocs.io/en/stable/tutorials/index.html)." + "Try to follow one of our [tutorials](https://pygsp.readthedocs.io/en/stable/tutorials/index.html), or go through my [computational introduction to GSP in NTDS'17](https://mybinder.org/v2/gh/mdeff/ntds_2017/outputs?urlpath=notebooks/demos/08_pygsp.ipynb)." ] }, { From b5d862ab2555f91ee79e4464b3530709f4db61c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 23 Jan 2018 14:58:03 +0100 Subject: [PATCH 367/392] history: fix formatting --- doc/history.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/history.rst b/doc/history.rst index 5c28e86a..e71e00bf 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -18,6 +18,7 @@ only loaded when needed, not when the PyGSP is imported. A nice side-effect is that importing the PyGSP is now much faster! The following packages were made optional dependencies: + * scikit-image, as it is only used to build patch graphs from images. The problem was that scikit-image does not provide a wheel for Windows and its build is painful and error-prone. Moreover, scikit-image has a lot of From a103991ed3e5195c9b9cad1202414c72fab29514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 23 Jan 2018 15:22:06 +0100 Subject: [PATCH 368/392] doc: formulation --- pygsp/optimization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygsp/optimization.py b/pygsp/optimization.py index 658673f1..d4b925bf 100644 --- a/pygsp/optimization.py +++ b/pygsp/optimization.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- r""" -The :mod:`pygsp.optimization` module provides tools for convex optimization on -graphs. +The :mod:`pygsp.optimization` module provides tools to solve convex +optimization problems on graphs. """ from pygsp import utils From 1e777ba0fc70bc5b4d26926f26586c230bfe4e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 23 Jan 2018 17:33:17 +0100 Subject: [PATCH 369/392] travis: deactivate tests to rebuild cache --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 835c6d85..9ab4a924 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,9 +19,7 @@ install: - pip install -U .[alldeps,test,doc] script: -# - make lint - - make test - - make doc + - echo 1 after_success: - coveralls From 1797a17e65cfd15e88f1c5fd87c8d7d2785ab557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 23 Jan 2018 17:34:08 +0100 Subject: [PATCH 370/392] Revert "travis: deactivate tests to rebuild cache" This reverts commit 1e777ba0fc70bc5b4d26926f26586c230bfe4e64. --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9ab4a924..835c6d85 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,9 @@ install: - pip install -U .[alldeps,test,doc] script: - - echo 1 +# - make lint + - make test + - make doc after_success: - coveralls From 71a113ce891138929080f6c1bdd7f88a06db95a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 24 Jan 2018 09:55:44 +0100 Subject: [PATCH 371/392] doctests: update numpy formatting --- pygsp/utils.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/pygsp/utils.py b/pygsp/utils.py index eaab3473..d2124b44 100644 --- a/pygsp/utils.py +++ b/pygsp/utils.py @@ -135,9 +135,9 @@ def distanz(x, y=None): >>> from pygsp import utils >>> x = np.arange(3) >>> utils.distanz(x, x) - array([[ 0., 1., 2.], - [ 1., 0., 1.], - [ 2., 1., 0.]]) + array([[0., 1., 2.], + [1., 0., 1.], + [2., 1., 0.]]) """ try: @@ -237,29 +237,29 @@ def symmetrize(W, method='average'): >>> from pygsp import utils >>> W = np.array([[0, 3, 0], [3, 1, 6], [4, 2, 3]], dtype=float) >>> W - array([[ 0., 3., 0.], - [ 3., 1., 6.], - [ 4., 2., 3.]]) + array([[0., 3., 0.], + [3., 1., 6.], + [4., 2., 3.]]) >>> utils.symmetrize(W, method='average') - array([[ 0., 3., 2.], - [ 3., 1., 4.], - [ 2., 4., 3.]]) + array([[0., 3., 2.], + [3., 1., 4.], + [2., 4., 3.]]) >>> utils.symmetrize(W, method='maximum') - array([[ 0., 3., 4.], - [ 3., 1., 6.], - [ 4., 6., 3.]]) + array([[0., 3., 4.], + [3., 1., 6.], + [4., 6., 3.]]) >>> utils.symmetrize(W, method='fill') - array([[ 0., 3., 4.], - [ 3., 1., 4.], - [ 4., 4., 3.]]) + array([[0., 3., 4.], + [3., 1., 4.], + [4., 4., 3.]]) >>> utils.symmetrize(W, method='tril') - array([[ 0., 3., 4.], - [ 3., 1., 2.], - [ 4., 2., 3.]]) + array([[0., 3., 4.], + [3., 1., 2.], + [4., 2., 3.]]) >>> utils.symmetrize(W, method='triu') - array([[ 0., 3., 0.], - [ 3., 1., 6.], - [ 0., 6., 3.]]) + array([[0., 3., 0.], + [3., 1., 6.], + [0., 6., 3.]]) """ if W.shape[0] != W.shape[1]: @@ -355,7 +355,7 @@ def compute_log_scales(lmin, lmax, Nscales, t1=1, t2=2): -------- >>> from pygsp import utils >>> utils.compute_log_scales(1, 10, 3) - array([ 2. , 0.4472136, 0.1 ]) + array([2. , 0.4472136, 0.1 ]) """ scale_min = t1 / lmax From 818e344e679c91fa0dbf24d265e9abe38055b7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sat, 10 Mar 2018 23:58:41 +0100 Subject: [PATCH 372/392] filters: callable kernels are private filters might not exist as kernel functions in the future (e.g. as Chebyshev coefficients), users should call evaluate() --- pygsp/filters/filter.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 472e07cb..b4d9a6e4 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -31,10 +31,6 @@ class Filter(object): G : Graph The graph to which the filter bank was tailored. It is a reference to the graph passed when instantiating the class. - kernels : function or list of functions - A (list of) function defining the filter bank. One function per filter. - Either passed by the user when instantiating the base class, either - constructed by the derived classes. Nf : int Number of filters in the filter bank. @@ -93,8 +89,8 @@ def evaluate(self, x): """ # Avoid to copy data as with np.array([g(x) for g in self._kernels]). y = np.empty((self.Nf, len(x))) - for i, g in enumerate(self._kernels): - y[i] = g(x) + for i, kernel in enumerate(self._kernels): + y[i] = kernel(x) return y def filter(self, s, method='chebyshev', order=30): From 1d9c1caf2a266d482a49a30cef7a3e1f006781e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Sun, 11 Mar 2018 11:08:50 +0100 Subject: [PATCH 373/392] filters: remove unused kwargs arguments --- pygsp/filters/abspline.py | 4 ++-- pygsp/filters/expwin.py | 4 ++-- pygsp/filters/halfcosine.py | 4 ++-- pygsp/filters/heat.py | 4 ++-- pygsp/filters/held.py | 4 ++-- pygsp/filters/itersine.py | 4 ++-- pygsp/filters/mexicanhat.py | 5 ++--- pygsp/filters/meyer.py | 4 ++-- pygsp/filters/papadakis.py | 4 ++-- pygsp/filters/regular.py | 4 ++-- pygsp/filters/simoncelli.py | 4 ++-- pygsp/filters/simpletight.py | 4 ++-- 12 files changed, 24 insertions(+), 25 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index 11a81c0e..df9cd9b9 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -40,7 +40,7 @@ class Abspline(Filter): """ - def __init__(self, G, Nf=6, lpfactor=20, scales=None, **kwargs): + def __init__(self, G, Nf=6, lpfactor=20, scales=None): def kernel_abspline3(x, alpha, beta, t1, t2): M = np.array([[1, t1, t1**2, t1**3], @@ -98,4 +98,4 @@ def kernel_abspline3(x, alpha, beta, t1, t2): lminfac = .6 * G.lmin g[0] = lambda x: gamma_l * gl(x / lminfac) - super(Abspline, self).__init__(G, g, **kwargs) + super(Abspline, self).__init__(G, g) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 29f789a5..bff22cd0 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -33,7 +33,7 @@ class Expwin(Filter): """ - def __init__(self, G, bmax=0.2, a=1., **kwargs): + def __init__(self, G, bmax=0.2, a=1.): def fx(x, a): y = np.exp(-float(a)/x) @@ -53,4 +53,4 @@ def ffin(x, a): g = [lambda x: ffin(np.float64(x)/bmax/G.lmax, a)] - super(Expwin, self).__init__(G, g, **kwargs) + super(Expwin, self).__init__(G, g) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index 9b5604ef..386035b7 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -31,7 +31,7 @@ class HalfCosine(Filter): """ - def __init__(self, G, Nf=6, **kwargs): + def __init__(self, G, Nf=6): if Nf <= 2: raise ValueError('The number of filters must be higher than 2.') @@ -45,4 +45,4 @@ def __init__(self, G, Nf=6, **kwargs): for i in range(Nf): g.append(lambda x, ind=i: main_window(x - dila_fact/3. * (ind - 2))) - super(HalfCosine, self).__init__(G, g, **kwargs) + super(HalfCosine, self).__init__(G, g) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index 1ec81822..dbe0e5da 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -61,7 +61,7 @@ class Heat(Filter): """ - def __init__(self, G, tau=10, normalize=False, **kwargs): + def __init__(self, G, tau=10, normalize=False): try: iter(tau) @@ -76,4 +76,4 @@ def kernel(x, t): norm = np.linalg.norm(kernel(G.e, t)) if normalize else 1 g.append(lambda x, t=t, norm=norm: kernel(x, t) / norm) - super(Heat, self).__init__(G, g, **kwargs) + super(Heat, self).__init__(G, g) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index bd224f50..a9126270 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -45,7 +45,7 @@ class Held(Filter): """ - def __init__(self, G, a=2./3, **kwargs): + def __init__(self, G, a=2./3): g = [lambda x: held(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - (held(x * (2./G.lmax), a)) @@ -67,4 +67,4 @@ def held(val, a): return y - super(Held, self).__init__(G, g, **kwargs) + super(Held, self).__init__(G, g) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 38f558d1..075ba3b0 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -35,7 +35,7 @@ class Itersine(Filter): >>> G.plot_signal(s, ax=axes[1]) """ - def __init__(self, G, Nf=6, overlap=2., **kwargs): + def __init__(self, G, Nf=6, overlap=2.): def k(x): return np.sin(0.5*np.pi*np.power(np.cos(x*np.pi), 2)) * ((x >= -0.5)*(x <= 0.5)) @@ -46,4 +46,4 @@ def k(x): for i in range(1, Nf + 1): g.append(lambda x, ind=i: k(x/scale - (ind - overlap/2.)/overlap) / np.sqrt(overlap)*np.sqrt(2)) - super(Itersine, self).__init__(G, g, **kwargs) + super(Itersine, self).__init__(G, g) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index b58ca2fe..9426e3fc 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -55,8 +55,7 @@ class MexicanHat(Filter): """ - def __init__(self, G, Nf=6, lpfactor=20, scales=None, normalize=False, - **kwargs): + def __init__(self, G, Nf=6, lpfactor=20, scales=None, normalize=False): lmin = G.lmax / lpfactor @@ -82,4 +81,4 @@ def kernel(x, i=i): kernels.append(kernel) - super(MexicanHat, self).__init__(G, kernels, **kwargs) + super(MexicanHat, self).__init__(G, kernels) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 7252e8c2..9466d0e8 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -42,7 +42,7 @@ class Meyer(Filter): """ - def __init__(self, G, Nf=6, scales=None, **kwargs): + def __init__(self, G, Nf=6, scales=None): if scales is None: scales = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) @@ -90,4 +90,4 @@ def v(x): return r - super(Meyer, self).__init__(G, g, **kwargs) + super(Meyer, self).__init__(G, g) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index 94f29821..2397a2e6 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -40,7 +40,7 @@ class Papadakis(Filter): >>> G.plot_signal(s, ax=axes[1]) """ - def __init__(self, G, a=0.75, **kwargs): + def __init__(self, G, a=0.75): g = [lambda x: papadakis(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - (papadakis(x*(2./G.lmax), a)) ** @@ -61,4 +61,4 @@ def papadakis(val, a): return y - super(Papadakis, self).__init__(G, g, **kwargs) + super(Papadakis, self).__init__(G, g) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 85c85987..21473cf9 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -48,7 +48,7 @@ class Regular(Filter): >>> G.plot_signal(s, ax=axes[1]) """ - def __init__(self, G, d=3, **kwargs): + def __init__(self, G, d=3): g = [lambda x: regular(x * (2./G.lmax), d)] g.append(lambda x: np.real(np.sqrt(1 - (regular(x * (2./G.lmax), d)) @@ -65,4 +65,4 @@ def regular(val, d): return np.sin(np.pi / 4.*(1 + output)) - super(Regular, self).__init__(G, g, **kwargs) + super(Regular, self).__init__(G, g) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 564197c9..07a82edd 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -41,7 +41,7 @@ class Simoncelli(Filter): """ - def __init__(self, G, a=2./3, **kwargs): + def __init__(self, G, a=2./3): g = [lambda x: simoncelli(x * (2./G.lmax), a)] g.append(lambda x: np.real(np.sqrt(1 - @@ -63,4 +63,4 @@ def simoncelli(val, a): return y - super(Simoncelli, self).__init__(G, g, **kwargs) + super(Simoncelli, self).__init__(G, g) diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 5a2dcc15..402a44aa 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -42,7 +42,7 @@ class SimpleTight(Filter): """ - def __init__(self, G, Nf=6, scales=None, **kwargs): + def __init__(self, G, Nf=6, scales=None): def kernel(x, kerneltype): r""" @@ -98,4 +98,4 @@ def h(x): for i in range(Nf - 1): g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) - super(SimpleTight, self).__init__(G, g, **kwargs) + super(SimpleTight, self).__init__(G, g) From 07aabeaa30376b6f812a3c0703123958d36018b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 14 Mar 2018 18:17:43 +0100 Subject: [PATCH 374/392] update readme --- README.rst | 36 +++++++++++++----- pygsp/data/readme_example_filter.png | Bin 0 -> 20881 bytes ...e_example.png => readme_example_graph.png} | Bin 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 pygsp/data/readme_example_filter.png rename pygsp/data/{readme_example.png => readme_example_graph.png} (100%) diff --git a/README.rst b/README.rst index 2d6c59d4..bae49d81 100644 --- a/README.rst +++ b/README.rst @@ -33,13 +33,11 @@ PyGSP: Graph Signal Processing in Python The PyGSP is a Python package to ease `Signal Processing on Graphs `_. -It is a free software, distributed under the BSD license, and -available on `PyPI `_. 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, @@ -60,8 +58,15 @@ main objects of the package. >>> from pygsp import graphs, filters >>> G = graphs.Logo() ->>> G.estimate_lmax() ->>> g = filters.Heat(G, tau=100) +>>> G.compute_fourier_basis() # Fourier to plot the eigenvalues. +>>> # G.estimate_lmax() is otherwise sufficient. +>>> g = filters.Heat(G, tau=50) +>>> g.plot() + +.. image:: ../pygsp/data/readme_example_filter.png + :alt: +.. image:: pygsp/data/readme_example_filter.png + :alt: Let's now create a graph signal: a set of three Kronecker deltas for that example. We can now look at one step of heat diffusion by filtering the deltas @@ -73,11 +78,11 @@ structure! >>> s = np.zeros(G.N) >>> s[DELTAS] = 1 >>> s = g.filter(s) ->>> G.plot_signal(s, highlight=DELTAS, backend='matplotlib') +>>> G.plot_signal(s, highlight=DELTAS) -.. image:: ../pygsp/data/readme_example.png +.. image:: ../pygsp/data/readme_example_graph.png :alt: -.. image:: pygsp/data/readme_example.png +.. image:: pygsp/data/readme_example_graph.png :alt: You can @@ -86,7 +91,7 @@ look at the `tutorials `_ to learn how to use it, or look at the `reference guide `_ -for an exhaustive documentation of the API. Enjoy the package! +for an exhaustive documentation of the API. Enjoy! Installation ------------ @@ -118,3 +123,16 @@ under grant 200021_154350 "Towards Signal Processing on Graphs". If you are using the library for your research, for the sake of reproducibility, please cite the version you used as indexed by `Zenodo `_. +Or cite the generic concept as:: + + @misc{pygsp, + author = {Michaël Defferrard and + Rodrigo Pena and + Alexandre Lafaye and + Basile Châtillon and + Nicolas Rod and + Lionel Martin}, + title = {PyGSP: Graph Signal Processing in Python}, + doi = {10.5281/zenodo.1003157}, + url = {https://doi.org/10.5281/zenodo.1003157}, + } diff --git a/pygsp/data/readme_example_filter.png b/pygsp/data/readme_example_filter.png new file mode 100644 index 0000000000000000000000000000000000000000..f97605d971bb55413e502a81afa48fac15237df9 GIT binary patch literal 20881 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 U=^a+|2=!BW8D;5wNkhN?1NzE#;Q#;t literal 0 HcmV?d00001 diff --git a/pygsp/data/readme_example.png b/pygsp/data/readme_example_graph.png similarity index 100% rename from pygsp/data/readme_example.png rename to pygsp/data/readme_example_graph.png From be42d86ba44ea1434ba85ab0ca2e4e33f6eb3312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 14 Mar 2018 18:27:10 +0100 Subject: [PATCH 375/392] release: update conda feedstock --- CONTRIBUTING.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b112891c..63604dcf 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -57,6 +57,9 @@ Making a release Log in as the LTS2 user. #. Build and upload the distribution to the real PyPI with ``make release``. +#. Update the conda feedstock (at least the version number and sha256 in + ``recipe/meta.yaml``) by sending a PR to + `conda-forge `_. Repository organization ----------------------- From cded8593e278c47c72e6c8318f10af328dc8d089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 15 Mar 2018 01:45:17 +0100 Subject: [PATCH 376/392] metadata for zenodo --- .zenodo.json | 39 +++++++++++++++++++++++++++++++++++++++ README.rst | 8 +++----- 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 .zenodo.json diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 00000000..95d305fe --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,39 @@ +{ + "title": "PyGSP: Graph Signal Processing in Python", + "description": "The PyGSP facilitates a wide variety of operations on graphs, like computing their Fourier basis, filtering or interpolating signals, plotting graphs, signals, and filters.", + "upload_type": "software", + "license": "BSD-3-Clause", + "access_right": "open", + "creators": [ + { + "name": "Micha\u00ebl Defferrard", + "affiliation": "EPFL", + "orcid": "0000-0002-6028-9024" + }, + { + "name": "Lionel Martin", + "affiliation": "EPFL" + }, + { + "name": "Rodrigo Pena", + "affiliation": "EPFL" + }, + { + "name": "Nathana\u00ebl Perraudin", + "affiliation": "EPFL", + "orcid": "0000-0001-8285-1308" + } + ], + "related_identifiers": [ + { + "scheme": "url", + "identifier": "https://github.com/epfl-lts2/pygsp", + "relation": "isSupplementTo" + }, + { + "scheme": "doi", + "identifier": "10.5281/zenodo.1003157", + "relation": "isPartOf" + } + ] +} diff --git a/README.rst b/README.rst index bae49d81..3a56cb82 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ PyGSP: Graph Signal Processing in Python :target: https://pygsp.readthedocs.io .. |pypi| image:: https://img.shields.io/pypi/v/pygsp.svg :target: https://pypi.python.org/pypi/PyGSP -.. |zenodo| image:: https://zenodo.org/badge/16276560.svg +.. |zenodo| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.1003157.svg :target: https://doi.org/10.5281/zenodo.1003157 .. |license| image:: https://img.shields.io/pypi/l/pygsp.svg :target: https://github.com/epfl-lts2/pygsp/blob/master/LICENSE.txt @@ -127,11 +127,9 @@ Or cite the generic concept as:: @misc{pygsp, author = {Michaël Defferrard and + Lionel Martin and Rodrigo Pena and - Alexandre Lafaye and - Basile Châtillon and - Nicolas Rod and - Lionel Martin}, + Nathanaël Perraudin}, title = {PyGSP: Graph Signal Processing in Python}, doi = {10.5281/zenodo.1003157}, url = {https://doi.org/10.5281/zenodo.1003157}, From 361f0258a210193f482c6197ea879765a9041e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Thu, 15 Mar 2018 15:12:02 +0100 Subject: [PATCH 377/392] readme: license and citation --- README.rst | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 3a56cb82..a7477516 100644 --- a/README.rst +++ b/README.rst @@ -120,17 +120,16 @@ research purpose at the `EPFL LTS2 laboratory `_. This project has been partly funded by the Swiss National Science Foundation under grant 200021_154350 "Towards Signal Processing on Graphs". +The code in this repository is released under the terms of the `BSD 3-Clause license `_. + If you are using the library for your research, for the sake of reproducibility, please cite the version you used as indexed by `Zenodo `_. Or cite the generic concept as:: @misc{pygsp, - author = {Michaël Defferrard and - Lionel Martin and - Rodrigo Pena and - Nathanaël Perraudin}, - title = {PyGSP: Graph Signal Processing in Python}, - doi = {10.5281/zenodo.1003157}, - url = {https://doi.org/10.5281/zenodo.1003157}, + title = {PyGSP: Graph Signal Processing in Python}, + author = {Defferrard, Micha\"el and Martin, Lionel and Pena, Rodrigo and Perraudin, Nathana\"el}, + doi = {10.5281/zenodo.1003157}, + url = {https://github.com/epfl-lts2/pygsp/}, } From bd4ed709afa81e6c13960fea5bb4ebd5ab2821c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 15:17:40 +0100 Subject: [PATCH 378/392] filter plotting: user can pass any argument to plt.plot --- pygsp/plotting.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index c6fde8aa..f3c7c733 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -345,8 +345,8 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): @_plt_handle_figure -def plot_filter(filters, npoints=1000, line_width=4, x_width=3, - x_size=10, plot_eigenvalues=None, show_sum=None, ax=None): +def plot_filter(filters, npoints=1000, x_width=3, x_size=10, + plot_eigenvalues=None, show_sum=None, ax=None, **kwargs): r""" Plot the spectral response of a filter bank, a set of graph filters. @@ -356,8 +356,6 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, Filter bank to plot. npoints : int Number of point where the filters are evaluated. - line_width : int - Width of the filters plots. x_width : int Width of the X marks representing the eigenvalues. x_size : int @@ -375,8 +373,12 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, Whether to save the plot as save_as.png and save_as.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes - Axes where to draw the graph. Optional, created if not passed. Only - available with the matplotlib backend. + Axes where to draw the graph. Optional, created if not passed. + Only available with the matplotlib backend. + kwargs : dict + Additional parameters passed to the matplotlib plot function. + Useful for example to change the linewidth, linestyle, or set a label. + Only available with the matplotlib backend. Examples -------- @@ -400,12 +402,12 @@ def plot_filter(filters, npoints=1000, line_width=4, x_width=3, x = np.linspace(0, G.lmax, npoints) y = filters.evaluate(x).T - ax.plot(x, y, linewidth=line_width) + ax.plot(x, y, **kwargs) # TODO: plot highlighted eigenvalues if show_sum: - ax.plot(x, np.sum(y**2, 1), 'k', linewidth=line_width) + ax.plot(x, np.sum(y**2, 1), 'k', **kwargs) ax.set_xlabel("$\lambda$: laplacian's eigenvalues / graph frequencies") ax.set_ylabel('$\hat{g}(\lambda)$: filter response') From bc73219c0402fed940b8f6da694fb9ea35bdbb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 15:31:02 +0100 Subject: [PATCH 379/392] plotting: API changes to fix bad names The following parameter names were changed: * plot_name => title * plot_eigenvalues => eigenvalues * show_sum => sum * show_edges => edges * npoints => n * save_as => save --- doc/tutorials/intro.rst | 2 +- pygsp/graphs/airfoil.py | 2 +- pygsp/graphs/nngraphs/twomoons.py | 2 +- pygsp/plotting.py | 107 ++++++++++++++---------------- pygsp/tests/test_plotting.py | 2 +- 5 files changed, 55 insertions(+), 60 deletions(-) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index dca40e6a..d8b09e37 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -193,7 +193,7 @@ let's define and plot that low-pass filter: >>> g = filters.Filter(G, g) >>> >>> fig, ax = plt.subplots() - >>> g.plot(plot_eigenvalues=True, ax=ax) + >>> g.plot(eigenvalues=True, ax=ax) >>> _ = ax.set_title('Filter frequency response') The filter is plotted along all the spectrum of the graph. The black crosses diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index d763a17a..34eb2234 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -16,7 +16,7 @@ class Airfoil(Graph): >>> G = graphs.Airfoil() >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=0.5) - >>> G.plot(show_edges=True, ax=axes[1]) + >>> G.plot(edges=True, ax=axes[1]) """ diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index f6b7b558..0b142e3e 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -40,7 +40,7 @@ class TwoMoons(NNGraph): >>> G = graphs.TwoMoons() >>> fig, axes = plt.subplots(1, 2) >>> _ = axes[0].spy(G.W, markersize=0.5) - >>> G.plot(show_edges=True, ax=axes[1]) + >>> G.plot(edges=True, ax=axes[1]) """ diff --git a/pygsp/plotting.py b/pygsp/plotting.py index f3c7c733..f98ba785 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -79,17 +79,17 @@ def inner(obj, *args, **kwargs): kwargs.update(ax=ax) - save_as = kwargs.pop('save_as', None) - plot_name = kwargs.pop('plot_name', '') + save = kwargs.pop('save', None) + title = kwargs.pop('title', '') plot(obj, *args, **kwargs) - kwargs['ax'].set_title(plot_name) + kwargs['ax'].set_title(title) try: - if save_as is not None: - fig.savefig(save_as + '.png') - fig.savefig(save_as + '.pdf') + if save is not None: + fig.savefig(save + '.png') + fig.savefig(save + '.pdf') else: fig.show(warn=False) except NameError: @@ -182,7 +182,7 @@ def plot_graph(G, backend=None, **kwargs): ---------- G : Graph Graph to plot. - show_edges : bool + edges : bool True to draw edges, false to only draw vertices. Default True if less than 10,000 edges to draw. Note that drawing a large number of edges might be particularly slow. @@ -190,10 +190,10 @@ def plot_graph(G, backend=None, **kwargs): Defines the drawing backend to use. Defaults to :data:`BACKEND`. vertex_size : float Size of circle representing each node. - plot_name : str - name of the plot - save_as : str - Whether to save the plot as save_as.png and save_as.pdf. Shown in a + title : str + Title of the figure. + save : str + Whether to save the plot as save.png and save.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. Only @@ -212,13 +212,13 @@ def plot_graph(G, backend=None, **kwargs): if (G.coords.ndim != 2) or (G.coords.shape[1] not in [2, 3]): raise AttributeError('Coordinates should be in 2D or 3D space.') - kwargs['show_edges'] = kwargs.pop('show_edges', G.Ne < 10e3) + kwargs['edges'] = kwargs.pop('edges', G.Ne < 10e3) default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - plot_name = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) - kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) + title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + kwargs['title'] = kwargs.pop('title', title) if backend is None: backend = BACKEND @@ -234,12 +234,12 @@ def plot_graph(G, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_graph(G, show_edges, vertex_size, ax): +def _plt_plot_graph(G, edges, vertex_size, ax): # TODO handling when G is a list of graphs # TODO integrate param when G is a clustered graph - if show_edges: + if edges: if G.is_directed(): raise NotImplementedError @@ -289,7 +289,7 @@ def _plt_plot_graph(G, show_edges, vertex_size, ax): pass -def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): +def _qtg_plot_graph(G, edges, vertex_size, title): # TODO handling when G is a list of graphs @@ -303,11 +303,11 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): if G.coords.shape[1] == 2: window = qtg.GraphicsWindow() - window.setWindowTitle(plot_name) + window.setWindowTitle(title) view = window.addViewBox() view.setAspectLocked() - if show_edges: + if edges: pen = tuple(np.array(G.plotting['edge_color']) * 255) else: pen = None @@ -327,9 +327,9 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() - widget.setWindowTitle(plot_name) + widget.setWindowTitle(title) - if show_edges: + if edges: x, y, z = _get_coords(G) pos = np.stack((x, y, z), axis=1) g = gl.GLLinePlotItem(pos=pos, mode='lines', @@ -345,8 +345,7 @@ def _qtg_plot_graph(G, show_edges, vertex_size, plot_name): @_plt_handle_figure -def plot_filter(filters, npoints=1000, x_width=3, x_size=10, - plot_eigenvalues=None, show_sum=None, ax=None, **kwargs): +def plot_filter(filters, n=500, eigenvalues=None, sum=None, ax=None, **kwargs): r""" Plot the spectral response of a filter bank, a set of graph filters. @@ -354,23 +353,19 @@ def plot_filter(filters, npoints=1000, x_width=3, x_size=10, ---------- filters : Filter Filter bank to plot. - npoints : int - Number of point where the filters are evaluated. - x_width : int - Width of the X marks representing the eigenvalues. - x_size : int - Size of the X marks representing the eigenvalues. - plot_eigenvalues : boolean + n : int + Number of points where the filters are evaluated. + eigenvalues : boolean To plot black X marks at all eigenvalues of the graph. You need to compute the Fourier basis to use this option. By default the eigenvalues are plot if they are contained in the Graph. - show_sum : boolean + sum : boolean To plot an extra line showing the sum of the squared magnitudes of the filters (default True if there is multiple filters). - plot_name : string - name of the plot - save_as : str - Whether to save the plot as save_as.png and save_as.pdf. Shown in a + title : str + Title of the figure. + save : str + Whether to save the plot as save.png and save.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. @@ -391,22 +386,22 @@ def plot_filter(filters, npoints=1000, x_width=3, x_size=10, G = filters.G - if plot_eigenvalues is None: - plot_eigenvalues = hasattr(G, '_e') - if show_sum is None: - show_sum = filters.Nf > 1 + if eigenvalues is None: + eigenvalues = hasattr(G, '_e') + if sum is None: + sum = filters.Nf > 1 - if plot_eigenvalues: + if eigenvalues: for e in G.e: ax.axvline(x=e, color=[0.9]*3, linewidth=1) - x = np.linspace(0, G.lmax, npoints) + x = np.linspace(0, G.lmax, n) y = filters.evaluate(x).T ax.plot(x, y, **kwargs) # TODO: plot highlighted eigenvalues - if show_sum: + if sum: ax.plot(x, np.sum(y**2, 1), 'k', **kwargs) ax.set_xlabel("$\lambda$: laplacian's eigenvalues / graph frequencies") @@ -423,7 +418,7 @@ def plot_signal(G, signal, backend=None, **kwargs): Graph to plot a signal on top. signal : array of int Signal to plot. Signal length should be equal to the number of nodes. - show_edges : bool + edges : bool True to draw edges, false to only draw vertices. Default True if less than 10,000 edges to draw. Note that drawing a large number of edges might be particularly slow. @@ -449,10 +444,10 @@ def plot_signal(G, signal, backend=None, **kwargs): NOT IMPLEMENTED. Width of the bar (default 1). backend: {'matplotlib', 'pyqtgraph'} Defines the drawing backend to use. Defaults to :data:`BACKEND`. - plot_name : string - Name of the plot. - save_as : str - Whether to save the plot as save_as.png and save_as.pdf. Shown in a + title : str + Title of the figure. + save : str + Whether to save the plot as save.png and save.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes Axes where to draw the graph. Optional, created if not passed. Only @@ -484,13 +479,13 @@ def plot_signal(G, signal, backend=None, **kwargs): if np.sum(np.abs(signal.imag)) > 1e-10: raise ValueError("Can't display complex signal.") - kwargs['show_edges'] = kwargs.pop('show_edges', G.Ne < 10e3) + kwargs['edges'] = kwargs.pop('edges', G.Ne < 10e3) default = G.plotting['vertex_size'] kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - plot_name = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) - kwargs['plot_name'] = kwargs.pop('plot_name', plot_name) + title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + kwargs['title'] = kwargs.pop('title', title) limits = [1.05*signal.min(), 1.05*signal.max()] kwargs['limits'] = kwargs.pop('limits', limits) @@ -509,10 +504,10 @@ def plot_signal(G, signal, backend=None, **kwargs): @_plt_handle_figure -def _plt_plot_signal(G, signal, show_edges, limits, ax, +def _plt_plot_signal(G, signal, edges, limits, ax, vertex_size, highlight=[], colorbar=True): - if show_edges: + if edges: if G.is_directed(): raise NotImplementedError @@ -578,12 +573,12 @@ def _plt_plot_signal(G, signal, show_edges, limits, ax, plt.colorbar(sc, ax=ax) -def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): +def _qtg_plot_signal(G, signal, edges, title, vertex_size, limits): qtg, gl, QtGui = _import_qtg() if G.coords.shape[1] == 2: - window = qtg.GraphicsWindow(plot_name) + window = qtg.GraphicsWindow(title) view = window.addViewBox() elif G.coords.shape[1] == 3: @@ -592,9 +587,9 @@ def _qtg_plot_signal(G, signal, show_edges, plot_name, vertex_size, limits): widget = gl.GLViewWidget() widget.opts['distance'] = 10 widget.show() - widget.setWindowTitle(plot_name) + widget.setWindowTitle(title) - if show_edges: + if edges: if G.is_directed(): raise NotImplementedError diff --git a/pygsp/tests/test_plotting.py b/pygsp/tests/test_plotting.py index 6706b75d..487ba393 100644 --- a/pygsp/tests/test_plotting.py +++ b/pygsp/tests/test_plotting.py @@ -85,7 +85,7 @@ def test_plot_graphs(self): def test_save(self): G = graphs.Logo() name = 'test_plot' - G.plot(backend='matplotlib', save_as=name) + G.plot(backend='matplotlib', save=name) os.remove(name + '.png') os.remove(name + '.pdf') From 12046f6ecb894039e68081c0fa7f114d4339985b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 17:25:47 +0100 Subject: [PATCH 380/392] plotting: for user convenience, move the doc in the main API Users only call the plot methods from the objects. It's thus more convenient for them to have the doc there. But it's more convenient for developers to have the doc alongside the code. Other advantages: * Default values are set in the main API (on graphs and filters objects). * All parameters have to be named. --- pygsp/__init__.py | 8 +++ pygsp/filters/filter.py | 13 ++-- pygsp/graphs/graph.py | 43 ++++++------ pygsp/plotting.py | 150 +++++++++++++++++++++------------------- 4 files changed, 111 insertions(+), 103 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index 15ef39c1..e25416cd 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -30,5 +30,13 @@ _utils.import_modules(__all__[::-1], 'pygsp', 'pygsp') +# Users only call the plot methods from the objects. +# It's thus more convenient for them to have the doc there. +# But it's more convenient for developers to have the doc alongside the code. +filters.Filter.plot.__doc__ = plotting._plot_filter.__doc__ +graphs.Graph.plot.__doc__ = plotting._plot_graph.__doc__ +graphs.Graph.plot_signal.__doc__ = plotting._plot_signal.__doc__ +graphs.Graph.plot_spectrogram.__doc__ = plotting._plot_spectrogram.__doc__ + __version__ = '0.5.1' __release_date__ = '2017-12-15' diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index b4d9a6e4..1277fc4f 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -497,10 +497,9 @@ def can_dual_func(g, n, x): return Filter(self.G, kernels) - def plot(self, **kwargs): - r"""Plot the filter bank's frequency response. - - See :func:`pygsp.plotting.plot_filter`. - """ - from pygsp import plotting - plotting.plot_filter(self, **kwargs) + def plot(self, n=500, eigenvalues=None, sum=None, title='', save=None, + ax=None, **kwargs): + r"""Docstring overloaded at import time.""" + from pygsp.plotting import _plot_filter + _plot_filter(self, n=n, eigenvalues=eigenvalues, sum=sum, title=title, + save=save, ax=ax, **kwargs) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 102c63b1..7e4633b3 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -658,29 +658,26 @@ def modulate(self, f, k): fm *= np.sqrt(self.N) return fm - def plot(self, **kwargs): - r"""Plot the graph. - - See :func:`pygsp.plotting.plot_graph`. - """ - from pygsp import plotting - plotting.plot_graph(self, **kwargs) - - def plot_signal(self, signal, **kwargs): - r"""Plot a signal on that graph. - - See :func:`pygsp.plotting.plot_signal`. - """ - from pygsp import plotting - plotting.plot_signal(self, signal, **kwargs) - - def plot_spectrogram(self, **kwargs): - r"""Plot the graph's spectrogram. - - See :func:`pygsp.plotting.plot_spectrogram`. - """ - from pygsp import plotting - plotting.plot_spectrogram(self, **kwargs) + def plot(self, edges=None, backend=None, vertex_size=None, title=None, + save=None, ax=None): + r"""Docstring overloaded at import time.""" + from pygsp.plotting import _plot_graph + _plot_graph(self, edges=edges, backend=backend, + vertex_size=vertex_size, title=title, save=save, ax=ax) + + def plot_signal(self, signal, edges=None, vertex_size=None, highlight=[], + colorbar=True, limits=None, backend=None, title=None, + save=None, ax=None): + r"""Docstring overloaded at import time.""" + from pygsp.plotting import _plot_signal + _plot_signal(self, signal=signal, edges=edges, vertex_size=vertex_size, + highlight=highlight, colorbar=colorbar, limits=limits, + backend=backend, title=title, save=save, ax=ax) + + 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, diff --git a/pygsp/plotting.py b/pygsp/plotting.py index f98ba785..b681e5c1 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -21,6 +21,8 @@ from __future__ import division +import functools + import numpy as np from pygsp import utils @@ -61,12 +63,15 @@ def _import_qtg(): def _plt_handle_figure(plot): - def inner(obj, *args, **kwargs): + # Preserve documentation of plot. + @functools.wraps(plot) + + def inner(obj, **kwargs): plt = _import_plt() # Create a figure and an axis if none were passed. - if 'ax' not in kwargs.keys(): + if kwargs['ax'] is None: fig = plt.figure() global _plt_figures _plt_figures.append(fig) @@ -77,12 +82,12 @@ def inner(obj, *args, **kwargs): else: ax = fig.add_subplot(111) - kwargs.update(ax=ax) + kwargs['ax'] = ax - save = kwargs.pop('save', None) - title = kwargs.pop('title', '') + save = kwargs.pop('save') + title = kwargs.pop('title') - plot(obj, *args, **kwargs) + plot(obj, **kwargs) kwargs['ax'].set_title(title) @@ -100,10 +105,7 @@ def inner(obj, *args, **kwargs): def close_all(): - r""" - Close all opened windows. - - """ + r"""Close all opened windows.""" # Windows can be closed by releasing all references to them so they can be # garbage collected. May not be necessary to call close(). @@ -125,23 +127,19 @@ def close_all(): def show(*args, **kwargs): - r""" - Show created figures. + r"""Show created figures. Alias to plt.show(). By default, showing plots does not block the prompt. - """ plt = _import_plt() plt.show(*args, **kwargs) def close(*args, **kwargs): - r""" - Close created figures. + r"""Close created figures. Alias to plt.close(). - """ plt = _import_plt() plt.close(*args, **kwargs) @@ -174,14 +172,11 @@ def plot(O, **kwargs): raise TypeError('Unrecognized object, i.e. not a Graph or Filter.') -def plot_graph(G, backend=None, **kwargs): - r""" - Plot a graph or a list of graphs. +def _plot_graph(G, edges, backend, vertex_size, title, save, ax): + r"""Plot the graph. Parameters ---------- - G : Graph - Graph to plot. edges : bool True to draw edges, false to only draw vertices. Default True if less than 10,000 edges to draw. @@ -190,20 +185,21 @@ def plot_graph(G, backend=None, **kwargs): Defines the drawing backend to use. Defaults to :data:`BACKEND`. vertex_size : float Size of circle representing each node. + Defaults to G.plotting['vertex_size']. title : str Title of the figure. save : str Whether to save the plot as save.png and save.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes - Axes where to draw the graph. Optional, created if not passed. Only - available with the matplotlib backend. + Axes where to draw the graph. Optional, created if not passed. + Only available with the matplotlib backend. Examples -------- - >>> from pygsp import plotting + >>> import matplotlib >>> G = graphs.Logo() - >>> plotting.plot_graph(G) + >>> G.plot() """ if not hasattr(G, 'coords'): @@ -212,23 +208,25 @@ def plot_graph(G, backend=None, **kwargs): if (G.coords.ndim != 2) or (G.coords.shape[1] not in [2, 3]): raise AttributeError('Coordinates should be in 2D or 3D space.') - kwargs['edges'] = kwargs.pop('edges', G.Ne < 10e3) - - default = G.plotting['vertex_size'] - kwargs['vertex_size'] = kwargs.pop('vertex_size', default) - - title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) - kwargs['title'] = kwargs.pop('title', title) + if edges is None: + edges = G.Ne < 10e3 if backend is None: backend = BACKEND + if vertex_size is None: + vertex_size = G.plotting['vertex_size'] + + if title is None: + title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + G = _handle_directed(G) if backend == 'pyqtgraph': - _qtg_plot_graph(G, **kwargs) + _qtg_plot_graph(G, edges=edges, vertex_size=vertex_size, title=title) elif backend == 'matplotlib': - _plt_plot_graph(G, **kwargs) + _plt_plot_graph(G, edges=edges, vertex_size=vertex_size, title=title, + save=save, ax=ax) else: raise ValueError('Unknown backend {}.'.format(backend)) @@ -345,14 +343,11 @@ def _qtg_plot_graph(G, edges, vertex_size, title): @_plt_handle_figure -def plot_filter(filters, n=500, eigenvalues=None, sum=None, ax=None, **kwargs): - r""" - Plot the spectral response of a filter bank, a set of graph filters. +def _plot_filter(filters, n, eigenvalues, sum, ax, **kwargs): + r"""Plot the spectral response of a filter bank, a set of graph filters. Parameters ---------- - filters : Filter - Filter bank to plot. n : int Number of points where the filters are evaluated. eigenvalues : boolean @@ -375,12 +370,16 @@ def plot_filter(filters, n=500, eigenvalues=None, sum=None, ax=None, **kwargs): Useful for example to change the linewidth, linestyle, or set a label. Only available with the matplotlib backend. + Notes + ----- + This function is only implemented for the matplotlib backend at the moment. + Examples -------- - >>> from pygsp import plotting + >>> import matplotlib >>> G = graphs.Logo() >>> mh = filters.MexicanHat(G) - >>> plotting.plot_filter(mh) + >>> mh.plot() """ @@ -408,14 +407,12 @@ def plot_filter(filters, n=500, eigenvalues=None, sum=None, ax=None, **kwargs): ax.set_ylabel('$\hat{g}(\lambda)$: filter response') -def plot_signal(G, signal, backend=None, **kwargs): - r""" - Plot a signal on top of a graph. +def _plot_signal(G, signal, edges, vertex_size, highlight, colorbar, + limits, backend, title, save, ax): + r"""Plot a signal on the graph. Parameters ---------- - G : Graph - Graph to plot a signal on top. signal : array of int Signal to plot. Signal length should be equal to the number of nodes. edges : bool @@ -426,6 +423,7 @@ def plot_signal(G, signal, backend=None, **kwargs): NOT IMPLEMENTED. Camera position when plotting a 3D graph. vertex_size : float Size of circle representing each node. + Defaults to G.plotting['vertex_size']. highlight : iterable List of indices of vertices to be highlighted. Useful to e.g. show where a filter was localized. @@ -450,15 +448,15 @@ def plot_signal(G, signal, backend=None, **kwargs): Whether to save the plot as save.png and save.pdf. Shown in a window if None (default). Only available with the matplotlib backend. ax : matplotlib.axes - Axes where to draw the graph. Optional, created if not passed. Only - available with the matplotlib backend. + Axes where to draw the graph. Optional, created if not passed. + Only available with the matplotlib backend. Examples -------- - >>> from pygsp import plotting - >>> G = graphs.Grid2d(4) - >>> signal = np.sin((np.arange(16) * 2*np.pi/16)) - >>> plotting.plot_signal(G, signal) + >>> import matplotlib + >>> G = graphs.Grid2d(8) + >>> signal = np.sin((np.arange(8**2) * 2*np.pi/8**2)) + >>> G.plot_signal(signal) """ if not hasattr(G, 'coords'): @@ -477,18 +475,19 @@ def plot_signal(G, signal, backend=None, **kwargs): raise ValueError('Signal length is {}, should be ' 'G.N = {}.'.format(signal.shape[0], G.N)) if np.sum(np.abs(signal.imag)) > 1e-10: - raise ValueError("Can't display complex signal.") + raise ValueError("Can't display complex signals.") - kwargs['edges'] = kwargs.pop('edges', G.Ne < 10e3) + if edges is None: + edges = G.Ne < 10e3 - default = G.plotting['vertex_size'] - kwargs['vertex_size'] = kwargs.pop('vertex_size', default) + if vertex_size is None: + vertex_size = G.plotting['vertex_size'] - title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) - kwargs['title'] = kwargs.pop('title', title) + if title is None: + title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) - limits = [1.05*signal.min(), 1.05*signal.max()] - kwargs['limits'] = kwargs.pop('limits', limits) + if limits is None: + limits = [1.05*signal.min(), 1.05*signal.max()] if backend is None: backend = BACKEND @@ -496,16 +495,20 @@ def plot_signal(G, signal, backend=None, **kwargs): G = _handle_directed(G) if backend == 'pyqtgraph': - _qtg_plot_signal(G, signal, **kwargs) + _qtg_plot_signal(G, signal=signal, edges=edges, + vertex_size=vertex_size, limits=limits, title=title) elif backend == 'matplotlib': - _plt_plot_signal(G, signal, **kwargs) + _plt_plot_signal(G, signal=signal, edges=edges, + vertex_size=vertex_size, limits=limits, title=title, + highlight=highlight, colorbar=colorbar, + save=save, ax=ax) else: raise ValueError('Unknown backend {}.'.format(backend)) @_plt_handle_figure -def _plt_plot_signal(G, signal, edges, limits, ax, - vertex_size, highlight=[], colorbar=True): +def _plt_plot_signal(G, signal, edges, vertex_size, highlight, colorbar, + limits, ax): if edges: @@ -573,7 +576,7 @@ def _plt_plot_signal(G, signal, edges, limits, ax, plt.colorbar(sc, ax=ax) -def _qtg_plot_signal(G, signal, edges, title, vertex_size, limits): +def _qtg_plot_signal(G, signal, edges, vertex_size, limits, title): qtg, gl, QtGui = _import_qtg() @@ -638,22 +641,23 @@ def _qtg_plot_signal(G, signal, edges, title, vertex_size, limits): _qtg_widgets.append(widget) -def plot_spectrogram(G, node_idx=None): - r""" - Plot the spectrogram of the given graph. +def _plot_spectrogram(G, node_idx): + r"""Plot the graph's spectrogram. Parameters ---------- - G : Graph - Graph to analyse. node_idx : ndarray - Order to sort the nodes in the spectrogram + Order to sort the nodes in the spectrogram. + By default, does not reorder the nodes. + + Notes + ----- + This function is only implemented for the pyqtgraph backend at the moment. Examples -------- - >>> from pygsp import plotting >>> G = graphs.Ring(15) - >>> plotting.plot_spectrogram(G) + >>> G.plot_spectrogram() """ from pygsp import features From 30bace01e9cf0b5fedd33147436b4b379b36421d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 18:13:52 +0100 Subject: [PATCH 381/392] fix for python 2.7 --- pygsp/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/pygsp/__init__.py b/pygsp/__init__.py index e25416cd..cbd127aa 100644 --- a/pygsp/__init__.py +++ b/pygsp/__init__.py @@ -33,10 +33,17 @@ # Users only call the plot methods from the objects. # It's thus more convenient for them to have the doc there. # But it's more convenient for developers to have the doc alongside the code. -filters.Filter.plot.__doc__ = plotting._plot_filter.__doc__ -graphs.Graph.plot.__doc__ = plotting._plot_graph.__doc__ -graphs.Graph.plot_signal.__doc__ = plotting._plot_signal.__doc__ -graphs.Graph.plot_spectrogram.__doc__ = plotting._plot_spectrogram.__doc__ +try: + filters.Filter.plot.__doc__ = plotting._plot_filter.__doc__ + graphs.Graph.plot.__doc__ = plotting._plot_graph.__doc__ + graphs.Graph.plot_signal.__doc__ = plotting._plot_signal.__doc__ + graphs.Graph.plot_spectrogram.__doc__ = plotting._plot_spectrogram.__doc__ +except AttributeError: + # For Python 2.7. + filters.Filter.plot.__func__.__doc__ = plotting._plot_filter.__doc__ + graphs.Graph.plot.__func__.__doc__ = plotting._plot_graph.__doc__ + graphs.Graph.plot_signal.__func__.__doc__ = plotting._plot_signal.__doc__ + graphs.Graph.plot_spectrogram.__func__.__doc__ = plotting._plot_spectrogram.__doc__ __version__ = '0.5.1' __release_date__ = '2017-12-15' From 2805ad6c6afae741faff8a7d117657b377f87582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 17:50:01 +0100 Subject: [PATCH 382/392] plotting: remove old API --- pygsp/plotting.py | 50 ++++++++++++----------------------------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index b681e5c1..ccb228bc 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -4,16 +4,19 @@ The :mod:`pygsp.plotting` module implements functionality to plot PyGSP objects with a `pyqtgraph `_ or `matplotlib `_ drawing backend (which can be controlled by the -:data:`BACKEND` constant or individually for each plotting call): +:data:`BACKEND` constant or individually for each plotting call). -* graphs from :mod:`pygsp.graphs` with :func:`plot_graph`, - :func:`plot_spectrogram`, and :func:`plot_signal`, -* filters from :mod:`pygsp.filters` with :func:`plot_filter`. +Most users won't use this module directly. +Graphs (from :mod:`pygsp.graphs`) are to be plotted with +:meth:`pygsp.graphs.Graph.plot`, :meth:`pygsp.graphs.Graph.plot_spectrogram`, +and :meth:`pygsp.graphs.Graph.plot_signal`. +Filters (from :mod:`pygsp.filters`) are to be plotted with +:meth:`pygsp.filters.Filter.plot`. .. data:: BACKEND Indicates which drawing backend to use if none are provided to the plotting - functions. Should be either 'matplotlib' or 'pyqtgraph'. In general + 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. @@ -62,6 +65,8 @@ def _import_qtg(): def _plt_handle_figure(plot): + r"""Handle the common work (creating an axis if not given, setting the + title, saving the created plot) of all matplotlib plot commands.""" # Preserve documentation of plot. @functools.wraps(plot) @@ -127,9 +132,8 @@ def close_all(): def show(*args, **kwargs): - r"""Show created figures. + r"""Show created figures, alias to plt.show(). - Alias to plt.show(). By default, showing plots does not block the prompt. """ plt = _import_plt() @@ -137,41 +141,11 @@ def show(*args, **kwargs): def close(*args, **kwargs): - r"""Close created figures. - - Alias to plt.close(). - """ + r"""Close created figures, alias to plt.close().""" plt = _import_plt() plt.close(*args, **kwargs) -def plot(O, **kwargs): - r""" - Main plotting function. - - This convenience function either calls :func:`plot_graph` or - :func:`plot_filter` given the type of the passed object. Parameters can be - passed to those functions. - - Parameters - ---------- - O : Graph, Filter - object to plot - - Examples - -------- - >>> from pygsp import plotting - >>> G = graphs.Logo() - >>> plotting.plot(G) - - """ - - try: - O.plot(**kwargs) - except AttributeError: - raise TypeError('Unrecognized object, i.e. not a Graph or Filter.') - - def _plot_graph(G, edges, backend, vertex_size, title, save, ax): r"""Plot the graph. From f8c221d346663e814333e2ba7899f4bbe82bd3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Fri, 16 Mar 2018 18:28:09 +0100 Subject: [PATCH 383/392] history: document plotting changes --- doc/history.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/history.rst b/doc/history.rst index e71e00bf..0637e0d5 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -2,6 +2,20 @@ History ======= +0.6.0 (2018-03-xx) +------------------ + +The plotting interface was updated to be more user-friendly. First, the +documentation is now shown for filters.plot(), G.plot(), and co. Second, the +API in the plotting library has been deprecated. That module is now mostly for +implementation only. Finally, the following parameter names were changed: +* plot_name => title +* plot_eigenvalues => eigenvalues +* show_sum => sum +* show_edges => edges +* npoints => n +* save_as => save + 0.5.1 (2017-12-15) ------------------ From 64339a512e19e34b3d0356b1f5920f7644ced7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Wed, 4 Apr 2018 00:57:47 +0200 Subject: [PATCH 384/392] much faster graph construction (avoid constructing matrices) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmark on the whole test suite (i.e. make test): * Before: Ran 140 tests in 336.127s * Avoid building LIL and TRIL: Ran 140 tests in 310.229s * Avoid building the CSR if not necessary: Ran 140 tests in 303.354s Benchmark pygsp.graphs.Graph(W) on large graph (800k nodes, 3M edges): * Before: 14.7 s ± 693 ms * After: 207 ms ± 15.4 ms --- doc/tutorials/intro.rst | 2 +- pygsp/graphs/graph.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/doc/tutorials/intro.rst b/doc/tutorials/intro.rst index d8b09e37..a3d9b654 100644 --- a/doc/tutorials/intro.rst +++ b/doc/tutorials/intro.rst @@ -68,7 +68,7 @@ We can retrieve our weight matrix, which is stored in a sparse format. >>> (G.W == W).all() True >>> type(G.W) - + We can access the `graph Laplacian`_ diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index 7e4633b3..e9ffc287 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -71,20 +71,31 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', 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 + else: + self.W = sparse.csr_matrix(W) + # Don't keep edges of 0 weight. Otherwise Ne will not correspond to the # real number of edges. Problematic when e.g. plotting. - W = sparse.csr_matrix(W) - W.eliminate_zeros() + self.W.eliminate_zeros() self.N = W.shape[0] - self.W = sparse.lil_matrix(W) + + # 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(): self.Ne = self.W.nnz else: - self.Ne = sparse.tril(W).nnz + diagonal = np.count_nonzero(self.W.diagonal()) + off_diagonal = self.W.nnz - diagonal + self.Ne = off_diagonal // 2 + diagonal self.check_weights() @@ -628,7 +639,7 @@ def get_edge_list(self): else: v_in, v_out = sparse.tril(self.W).nonzero() weights = self.W[v_in, v_out] - weights = weights.toarray().squeeze() + weights = np.asarray(weights).squeeze() # TODO G.ind_edges = sub2ind(size(G.W), G.v_in, G.v_out) From ab7db974a30f9c31fe41412736ea413f9fd20e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Mon, 9 Apr 2018 22:38:59 +0200 Subject: [PATCH 385/392] add project urls (used by new pypi based on warehouse) --- README.rst | 2 +- setup.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a7477516..5ae3f297 100644 --- a/README.rst +++ b/README.rst @@ -13,7 +13,7 @@ PyGSP: Graph Signal Processing in Python .. |doc| image:: https://readthedocs.org/projects/pygsp/badge/?version=latest :target: https://pygsp.readthedocs.io .. |pypi| image:: https://img.shields.io/pypi/v/pygsp.svg - :target: https://pypi.python.org/pypi/PyGSP + :target: https://pypi.org/project/PyGSP .. |zenodo| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.1003157.svg :target: https://doi.org/10.5281/zenodo.1003157 .. |license| image:: https://img.shields.io/pypi/l/pygsp.svg diff --git a/setup.py b/setup.py index bcf0ccd9..5d0e2143 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,12 @@ long_description=open('README.rst').read(), author='EPFL LTS2', url='https://github.com/epfl-lts2/pygsp', + project_urls={ + 'Documentation': 'https://pygsp.readthedocs.io', + 'Source Code': 'https://github.com/epfl-lts2/pygsp', + 'Bug Tracker': 'https://github.com/epfl-lts2/pygsp/issues', + 'Try It Online': 'https://mybinder.org/v2/gh/epfl-lts2/pygsp/master?filepath=playground.ipynb', + }, packages=[ 'pygsp', 'pygsp.graphs', From 6914f3c873b5d2d5c52a44ecf5af5a006e643418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 10 Apr 2018 01:26:13 +0200 Subject: [PATCH 386/392] expwin: clean and fix div by zero --- pygsp/filters/expwin.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index bff22cd0..4b2c9c76 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from . import Filter # prevent circular import in Python < 3.5 @@ -11,9 +13,9 @@ class Expwin(Filter): Parameters ---------- G : graph - bmax : float + band_max : float Maximum relative band (default = 0.2) - a : int + slope : float Slope parameter (default = 1) Examples @@ -33,24 +35,21 @@ class Expwin(Filter): """ - def __init__(self, G, bmax=0.2, a=1.): + def __init__(self, G, band_max=0.2, slope=1): - def fx(x, a): - y = np.exp(-float(a)/x) - if isinstance(x, np.ndarray): - y = np.where(x <= 0, 0., y) - else: - if x <= 0: - y = 0. - return y + def fx(x): + # Canary to avoid division by zero and overflow. + y = np.where(x <= 0, -1, x) + y = np.exp(-slope / y) + return np.where(x <= 0, 0, y) - def gx(x, a): - y = fx(x, a) - return y/(y + fx(1 - x, a)) + def gx(x): + y = fx(x) + return y / (y + fx(1 - x)) - def ffin(x, a): - return gx(1 - x, a) + def ffin(x): + return gx(1 - x) - g = [lambda x: ffin(np.float64(x)/bmax/G.lmax, a)] + g = [lambda x: ffin(x/band_max/G.lmax)] super(Expwin, self).__init__(G, g) From dc8ba6298ad800e838d1af6d94192bf5ec8a2946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 10 Apr 2018 01:50:43 +0200 Subject: [PATCH 387/392] clean many filters --- pygsp/filters/abspline.py | 17 +++++++++-------- pygsp/filters/gabor.py | 16 ++++++++-------- pygsp/filters/halfcosine.py | 21 +++++++++++++++------ pygsp/filters/heat.py | 7 ++++--- pygsp/filters/held.py | 12 +++++++----- pygsp/filters/itersine.py | 24 +++++++++++++++++------- pygsp/filters/mexicanhat.py | 2 +- pygsp/filters/meyer.py | 6 +++--- pygsp/filters/papadakis.py | 11 +++++++---- pygsp/filters/regular.py | 31 ++++++++++++++++++------------- pygsp/filters/simoncelli.py | 17 ++++++++++------- pygsp/filters/simpletight.py | 6 +++--- pygsp/tests/test_filters.py | 4 ++-- 13 files changed, 104 insertions(+), 70 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index df9cd9b9..f7adee10 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from scipy import optimize @@ -15,7 +17,7 @@ class Abspline(Filter): G : graph Nf : int Number of filters from 0 to lmax (default = 6) - lpfactor : int + lpfactor : float Low-pass factor lmin=lmax/lpfactor will be used to determine scales, the scaling function will be created to fill the lowpass gap. (default = 20) @@ -75,27 +77,26 @@ def kernel_abspline3(x, alpha, beta, t1, t2): return r - G.lmin = G.lmax / lpfactor + lmin = G.lmax / lpfactor if scales is None: - self.scales = utils.compute_log_scales(G.lmin, G.lmax, Nf - 1) - else: - self.scales = scales + scales = utils.compute_log_scales(lmin, G.lmax, Nf - 1) + self.scales = scales gb = lambda x: kernel_abspline3(x, 2, 2, 1, 2) gl = lambda x: np.exp(-np.power(x, 4)) - lminfac = .4 * G.lmin + lminfac = .4 * lmin g = [lambda x: 1.2 * np.exp(-1) * gl(x / lminfac)] for i in range(0, Nf - 1): - g.append(lambda x, ind=i: gb(self.scales[ind] * x)) + g.append(lambda x, i=i: gb(self.scales[i] * x)) f = lambda x: -gb(x) xstar = optimize.minimize_scalar(f, bounds=(1, 2), method='bounded') gamma_l = -f(xstar.x) - lminfac = .6 * G.lmin + lminfac = .6 * lmin g[0] = lambda x: gamma_l * gl(x / lminfac) super(Abspline, self).__init__(G, g) diff --git a/pygsp/filters/gabor.py b/pygsp/filters/gabor.py index 08fe1b1c..deaf2a76 100644 --- a/pygsp/filters/gabor.py +++ b/pygsp/filters/gabor.py @@ -10,14 +10,13 @@ class Gabor(Filter): r"""Design a Gabor filter bank. - Design a filter bank where the kernel *k* is placed at each graph - frequency. + Design a filter bank where the kernel is centered at each graph frequency. Parameters ---------- G : graph - k : lambda function - kernel + kernel : function + Kernel function to be centered and evaluated. Examples -------- @@ -26,12 +25,13 @@ class Gabor(Filter): >>> g = filters.Gabor(G, k); """ - def __init__(self, G, k): + + def __init__(self, G, kernel): Nf = G.e.shape[0] - g = [] + kernels = [] for i in range(Nf): - g.append(lambda x, ii=i: k(x - G.e[ii])) + kernels.append(lambda x, i=i: kernel(x - G.e[i])) - super(Gabor, self).__init__(G, g) + super(Gabor, self).__init__(G, kernels) diff --git a/pygsp/filters/halfcosine.py b/pygsp/filters/halfcosine.py index 386035b7..4e1889f9 100644 --- a/pygsp/filters/halfcosine.py +++ b/pygsp/filters/halfcosine.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from . import Filter # prevent circular import in Python < 3.5 @@ -34,15 +36,22 @@ class HalfCosine(Filter): def __init__(self, G, Nf=6): if Nf <= 2: - raise ValueError('The number of filters must be higher than 2.') + raise ValueError('The number of filters must be greater than 2.') - dila_fact = G.lmax * (3./(Nf - 2)) + dila_fact = G.lmax * 3 / (Nf - 2) - main_window = lambda x: np.multiply(np.multiply((.5 + .5*np.cos(2.*np.pi*(x/dila_fact - 1./2))), (x >= 0)), (x <= dila_fact)) + def kernel(x): + y = np.cos(2 * np.pi * (x / dila_fact - .5)) + y = np.multiply((.5 + .5*y), (x >= 0)) + return np.multiply(y, (x <= dila_fact)) - g = [] + kernels = [] for i in range(Nf): - g.append(lambda x, ind=i: main_window(x - dila_fact/3. * (ind - 2))) - super(HalfCosine, self).__init__(G, g) + def kernel_centered(x, i=i): + return kernel(x - dila_fact/3 * (i - 2)) + + kernels.append(kernel_centered) + + super(HalfCosine, self).__init__(G, kernels) diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index dbe0e5da..f015863b 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -71,9 +71,10 @@ def __init__(self, G, tau=10, normalize=False): def kernel(x, t): return np.exp(-t * x / G.lmax) - g = [] + kernels = [] for t in tau: norm = np.linalg.norm(kernel(G.e, t)) if normalize else 1 - g.append(lambda x, t=t, norm=norm: kernel(x, t) / norm) + kernels.append(lambda x, t=t, norm=norm: kernel(x, t) / norm) + + super(Heat, self).__init__(G, kernels) - super(Heat, self).__init__(G, g) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index a9126270..f23ff253 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -19,7 +19,7 @@ class Held(Filter): .. math:: \mu(x) = -1+24x-144*x^2+256*x^3 - The high pass filter is adaptated to obtain a tight frame. + The high pass filter is adapted to obtain a tight frame. Parameters ---------- @@ -47,9 +47,11 @@ class Held(Filter): def __init__(self, G, a=2./3): - g = [lambda x: held(x * (2./G.lmax), a)] - g.append(lambda x: np.real(np.sqrt(1 - (held(x * (2./G.lmax), a)) - ** 2))) + kernels = [lambda x: held(x * (2./G.lmax), a)] + def dual(x): + y = held(x * (2./G.lmax), a) + return np.real(np.sqrt(1 - y**2)) + kernels.append(dual) def held(val, a): y = np.empty(np.shape(val)) @@ -67,4 +69,4 @@ def held(val, a): return y - super(Held, self).__init__(G, g) + super(Held, self).__init__(G, kernels) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index 075ba3b0..c826ad63 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from . import Filter # prevent circular import in Python < 3.5 @@ -35,15 +37,23 @@ class Itersine(Filter): >>> G.plot_signal(s, ax=axes[1]) """ - def __init__(self, G, Nf=6, overlap=2.): - def k(x): - return np.sin(0.5*np.pi*np.power(np.cos(x*np.pi), 2)) * ((x >= -0.5)*(x <= 0.5)) + def __init__(self, G, Nf=6, overlap=2): + + scales = G.lmax / (Nf - overlap + 1) * overlap - scale = G.lmax/(Nf - overlap + 1.)*overlap - g = [] + def kernel(x): + y = np.cos(x * np.pi)**2 + y = np.sin(0.5 * np.pi * y) + return y * ((x >= -0.5) * (x <= 0.5)) + kernels = [] for i in range(1, Nf + 1): - g.append(lambda x, ind=i: k(x/scale - (ind - overlap/2.)/overlap) / np.sqrt(overlap)*np.sqrt(2)) - super(Itersine, self).__init__(G, g) + def kernel_centered(x, i=i): + y = kernel(x / scales - (i - overlap / 2) / overlap) + return y * np.sqrt(2 / overlap) + + kernels.append(kernel_centered) + + super(Itersine, self).__init__(G, kernels) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 9426e3fc..869ea928 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -29,7 +29,7 @@ class MexicanHat(Filter): G : graph Nf : int Number of filters to cover the interval [0, lmax]. - lpfactor : int + 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 diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 9466d0e8..6ff992bc 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -50,10 +50,10 @@ def __init__(self, G, Nf=6, scales=None): if len(scales) != Nf - 1: raise ValueError('len(scales) should be Nf-1.') - g = [lambda x: kernel(scales[0] * x, 'scaling_function')] + kernels = [lambda x: kernel(scales[0] * x, 'scaling_function')] for i in range(Nf - 1): - g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) + kernels.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) def kernel(x, kernel_type): r""" @@ -90,4 +90,4 @@ def v(x): return r - super(Meyer, self).__init__(G, g) + super(Meyer, self).__init__(G, kernels) diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index 2397a2e6..348d1d4c 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -40,11 +40,14 @@ class Papadakis(Filter): >>> G.plot_signal(s, ax=axes[1]) """ + def __init__(self, G, a=0.75): - g = [lambda x: papadakis(x * (2./G.lmax), a)] - g.append(lambda x: np.real(np.sqrt(1 - (papadakis(x*(2./G.lmax), a)) ** - 2))) + kernels = [lambda x: papadakis(x * (2./G.lmax), a)] + def dual(x): + y = papadakis(x * (2./G.lmax), a) + return np.real(np.sqrt(1 - y**2)) + kernels.append(dual) def papadakis(val, a): y = np.empty(np.shape(val)) @@ -61,4 +64,4 @@ def papadakis(val, a): return y - super(Papadakis, self).__init__(G, g) + super(Papadakis, self).__init__(G, kernels) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 21473cf9..1f98cecc 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from . import Filter # prevent circular import in Python < 3.5 @@ -29,7 +31,7 @@ class Regular(Filter): Parameters ---------- G : graph - d : float + degree : float Degree (default = 3). See above equations. Examples @@ -48,21 +50,24 @@ class Regular(Filter): >>> G.plot_signal(s, ax=axes[1]) """ - def __init__(self, G, d=3): - g = [lambda x: regular(x * (2./G.lmax), d)] - g.append(lambda x: np.real(np.sqrt(1 - (regular(x * (2./G.lmax), d)) - ** 2))) + def __init__(self, G, degree=3): + + kernels = [lambda x: regular(x * (2/G.lmax), degree)] + def dual(x): + y = regular(x * (2/G.lmax), degree) + return np.real(np.sqrt(1 - y**2)) + kernels.append(dual) - def regular(val, d): - if d == 0: - return np.sin(np.pi / 4.*val) + def regular(val, degree): + if degree == 0: + return np.sin(np.pi / 4*val) else: - output = np.sin(np.pi*(val - 1) / 2.) - for i in range(2, d): - output = np.sin(np.pi*output / 2.) + output = np.sin(np.pi*(val - 1) / 2) + for i in range(2, degree): + output = np.sin(np.pi*output / 2) - return np.sin(np.pi / 4.*(1 + output)) + return np.sin(np.pi / 4*(1 + output)) - super(Regular, self).__init__(G, g) + super(Regular, self).__init__(G, kernels) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 07a82edd..0b829536 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from __future__ import division + import numpy as np from . import Filter # prevent circular import in Python < 3.5 @@ -41,12 +43,13 @@ class Simoncelli(Filter): """ - def __init__(self, G, a=2./3): + def __init__(self, G, a=2/3): - g = [lambda x: simoncelli(x * (2./G.lmax), a)] - g.append(lambda x: np.real(np.sqrt(1 - - (simoncelli(x*(2./G.lmax), a)) - ** 2))) + kernels = [lambda x: simoncelli(x * (2/G.lmax), a)] + def dual(x): + y = simoncelli(x * (2/G.lmax), a) + return np.real(np.sqrt(1 - y**2)) + kernels.append(dual) def simoncelli(val, a): y = np.empty(np.shape(val)) @@ -58,9 +61,9 @@ def simoncelli(val, a): r3ind = (val >= l2) y[r1ind] = 1 - y[r2ind] = np.cos(np.pi/2 * np.log(val[r2ind]/float(a)) / np.log(2)) + y[r2ind] = np.cos(np.pi/2 * np.log(val[r2ind]/a) / np.log(2)) y[r3ind] = 0 return y - super(Simoncelli, self).__init__(G, g) + super(Simoncelli, self).__init__(G, kernels) diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 402a44aa..3aae880d 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -93,9 +93,9 @@ def h(x): if len(scales) != Nf - 1: raise ValueError('len(scales) should be Nf-1.') - g = [lambda x: kernel(scales[0] * x, 'sf')] + kernels = [lambda x: kernel(scales[0] * x, 'sf')] for i in range(Nf - 1): - g.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) + kernels.append(lambda x, i=i: kernel(scales[i] * x, 'wavelet')) - super(SimpleTight, self).__init__(G, g) + super(SimpleTight, self).__init__(G, kernels) diff --git a/pygsp/tests/test_filters.py b/pygsp/tests/test_filters.py index 9807bd5a..3a43b9f6 100644 --- a/pygsp/tests/test_filters.py +++ b/pygsp/tests/test_filters.py @@ -175,9 +175,9 @@ def test_simpletf(self): def test_regular(self): f = filters.Regular(self._G) self._test_methods(f, tight=True) - f = filters.Regular(self._G, d=5) + f = filters.Regular(self._G, degree=5) self._test_methods(f, tight=True) - f = filters.Regular(self._G, d=0) + f = filters.Regular(self._G, degree=0) self._test_methods(f, tight=True) def test_held(self): From 9c44c5c1f5e9b8de4fb124509670efae36c363f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 10 Apr 2018 01:54:36 +0200 Subject: [PATCH 388/392] string representation of filters for print --- pygsp/filters/abspline.py | 5 +++++ pygsp/filters/expwin.py | 7 +++++++ pygsp/filters/filter.py | 18 ++++++++++++++++-- pygsp/filters/heat.py | 6 ++++++ pygsp/filters/held.py | 5 +++++ pygsp/filters/itersine.py | 5 +++++ pygsp/filters/mexicanhat.py | 8 ++++++++ pygsp/filters/meyer.py | 1 + pygsp/filters/papadakis.py | 5 +++++ pygsp/filters/regular.py | 2 ++ pygsp/filters/simoncelli.py | 5 +++++ pygsp/filters/simpletight.py | 1 + pygsp/plotting.py | 28 ++++++++++++++++++---------- 13 files changed, 84 insertions(+), 12 deletions(-) diff --git a/pygsp/filters/abspline.py b/pygsp/filters/abspline.py index f7adee10..02255b40 100644 --- a/pygsp/filters/abspline.py +++ b/pygsp/filters/abspline.py @@ -77,6 +77,8 @@ def kernel_abspline3(x, alpha, beta, t1, t2): return r + self.lpfactor = lpfactor + lmin = G.lmax / lpfactor if scales is None: @@ -100,3 +102,6 @@ def kernel_abspline3(x, alpha, beta, t1, t2): g[0] = lambda x: gamma_l * gl(x / lminfac) super(Abspline, self).__init__(G, g) + + def _get_extra_repr(self): + return dict(lpfactor='{:.2f}'.format(self.lpfactor)) diff --git a/pygsp/filters/expwin.py b/pygsp/filters/expwin.py index 4b2c9c76..f0d22b39 100644 --- a/pygsp/filters/expwin.py +++ b/pygsp/filters/expwin.py @@ -37,6 +37,9 @@ class Expwin(Filter): def __init__(self, G, band_max=0.2, slope=1): + self.band_max = band_max + self.slope = slope + def fx(x): # Canary to avoid division by zero and overflow. y = np.where(x <= 0, -1, x) @@ -53,3 +56,7 @@ def ffin(x): g = [lambda x: ffin(x/band_max/G.lmax)] super(Expwin, self).__init__(G, g) + + def _get_extra_repr(self): + return dict(band_max='{:.2f}'.format(self.band_max), + slope='{:.2f}'.format(self.slope)) diff --git a/pygsp/filters/filter.py b/pygsp/filters/filter.py index 1277fc4f..9f5df1bf 100644 --- a/pygsp/filters/filter.py +++ b/pygsp/filters/filter.py @@ -58,7 +58,21 @@ def __init__(self, G, kernels): kernels = [kernels] self._kernels = kernels - self.Nf = len(kernels) + # Only used by subclasses to instantiate a single filterbank. + self.n_features_in, self.n_features_out = (1, len(kernels)) + self.n_filters = self.n_features_in * self.n_features_out + self.Nf = self.n_filters # TODO: kept for backward compatibility only. + + def _get_extra_repr(self): + return dict() + + def __repr__(self): + attrs = {'in': self.n_features_in, 'out': self.n_features_out} + attrs.update(self._get_extra_repr()) + s = '' + for key, value in attrs.items(): + s += '{}={}, '.format(key, value) + return '{}({})'.format(self.__class__.__name__, s[:-2]) def evaluate(self, x): r"""Evaluate the kernels at given frequencies. @@ -497,7 +511,7 @@ def can_dual_func(g, n, x): return Filter(self.G, kernels) - def plot(self, n=500, eigenvalues=None, sum=None, title='', save=None, + def plot(self, n=500, eigenvalues=None, sum=None, title=None, save=None, ax=None, **kwargs): r"""Docstring overloaded at import time.""" from pygsp.plotting import _plot_filter diff --git a/pygsp/filters/heat.py b/pygsp/filters/heat.py index f015863b..e050c7bc 100644 --- a/pygsp/filters/heat.py +++ b/pygsp/filters/heat.py @@ -68,6 +68,9 @@ def __init__(self, G, tau=10, normalize=False): except TypeError: tau = [tau] + self.tau = tau + self.normalize = normalize + def kernel(x, t): return np.exp(-t * x / G.lmax) @@ -78,3 +81,6 @@ def kernel(x, t): super(Heat, self).__init__(G, kernels) + def _get_extra_repr(self): + tau = '[' + ', '.join('{:.2f}'.format(t) for t in self.tau) + ']' + return dict(tau=tau, normalize=self.normalize) diff --git a/pygsp/filters/held.py b/pygsp/filters/held.py index f23ff253..a4babf6e 100644 --- a/pygsp/filters/held.py +++ b/pygsp/filters/held.py @@ -47,6 +47,8 @@ class Held(Filter): def __init__(self, G, a=2./3): + self.a = a + kernels = [lambda x: held(x * (2./G.lmax), a)] def dual(x): y = held(x * (2./G.lmax), a) @@ -70,3 +72,6 @@ def held(val, a): return y super(Held, self).__init__(G, kernels) + + def _get_extra_repr(self): + return dict(a='{:.2f}'.format(self.a)) diff --git a/pygsp/filters/itersine.py b/pygsp/filters/itersine.py index c826ad63..0df1c30c 100644 --- a/pygsp/filters/itersine.py +++ b/pygsp/filters/itersine.py @@ -40,6 +40,8 @@ class Itersine(Filter): def __init__(self, G, Nf=6, overlap=2): + self.overlap = overlap + scales = G.lmax / (Nf - overlap + 1) * overlap def kernel(x): @@ -57,3 +59,6 @@ def kernel_centered(x, i=i): kernels.append(kernel_centered) super(Itersine, self).__init__(G, kernels) + + def _get_extra_repr(self): + return dict(overlap='{:.2f}'.format(self.overlap)) diff --git a/pygsp/filters/mexicanhat.py b/pygsp/filters/mexicanhat.py index 869ea928..129c5447 100644 --- a/pygsp/filters/mexicanhat.py +++ b/pygsp/filters/mexicanhat.py @@ -57,10 +57,14 @@ class MexicanHat(Filter): def __init__(self, G, Nf=6, lpfactor=20, scales=None, normalize=False): + self.lpfactor = lpfactor + self.normalize = normalize + lmin = G.lmax / lpfactor if scales is None: scales = utils.compute_log_scales(lmin, G.lmax, Nf-1) + self.scales = scales if len(scales) != Nf - 1: raise ValueError('len(scales) should be Nf-1.') @@ -82,3 +86,7 @@ def kernel(x, i=i): kernels.append(kernel) super(MexicanHat, self).__init__(G, kernels) + + def _get_extra_repr(self): + return dict(lpfactor='{:.2f}'.format(self.lpfactor), + normalize=self.normalize) diff --git a/pygsp/filters/meyer.py b/pygsp/filters/meyer.py index 6ff992bc..17107ff0 100644 --- a/pygsp/filters/meyer.py +++ b/pygsp/filters/meyer.py @@ -46,6 +46,7 @@ def __init__(self, G, Nf=6, scales=None): if scales is None: scales = (4./(3 * G.lmax)) * np.power(2., np.arange(Nf-2, -1, -1)) + self.scales = scales if len(scales) != Nf - 1: raise ValueError('len(scales) should be Nf-1.') diff --git a/pygsp/filters/papadakis.py b/pygsp/filters/papadakis.py index 348d1d4c..7750d37b 100644 --- a/pygsp/filters/papadakis.py +++ b/pygsp/filters/papadakis.py @@ -43,6 +43,8 @@ class Papadakis(Filter): def __init__(self, G, a=0.75): + self.a = a + kernels = [lambda x: papadakis(x * (2./G.lmax), a)] def dual(x): y = papadakis(x * (2./G.lmax), a) @@ -65,3 +67,6 @@ def papadakis(val, a): return y super(Papadakis, self).__init__(G, kernels) + + def _get_extra_repr(self): + return dict(a='{:.2f}'.format(self.a)) diff --git a/pygsp/filters/regular.py b/pygsp/filters/regular.py index 1f98cecc..54f06d10 100644 --- a/pygsp/filters/regular.py +++ b/pygsp/filters/regular.py @@ -53,6 +53,8 @@ class Regular(Filter): def __init__(self, G, degree=3): + self.degree = degree + kernels = [lambda x: regular(x * (2/G.lmax), degree)] def dual(x): y = regular(x * (2/G.lmax), degree) diff --git a/pygsp/filters/simoncelli.py b/pygsp/filters/simoncelli.py index 0b829536..cd70f44b 100644 --- a/pygsp/filters/simoncelli.py +++ b/pygsp/filters/simoncelli.py @@ -45,6 +45,8 @@ class Simoncelli(Filter): def __init__(self, G, a=2/3): + self.a = a + kernels = [lambda x: simoncelli(x * (2/G.lmax), a)] def dual(x): y = simoncelli(x * (2/G.lmax), a) @@ -67,3 +69,6 @@ def simoncelli(val, a): return y super(Simoncelli, self).__init__(G, kernels) + + def _get_extra_repr(self): + return dict(a='{:.2f}'.format(self.a)) diff --git a/pygsp/filters/simpletight.py b/pygsp/filters/simpletight.py index 3aae880d..e8f6ca66 100644 --- a/pygsp/filters/simpletight.py +++ b/pygsp/filters/simpletight.py @@ -89,6 +89,7 @@ def h(x): if not scales: scales = (1./(2.*G.lmax) * np.power(2, np.arange(Nf-2, -1, -1))) + self.scales = scales if len(scales) != Nf - 1: raise ValueError('len(scales) should be Nf-1.') diff --git a/pygsp/plotting.py b/pygsp/plotting.py index ccb228bc..0e999b43 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -316,8 +316,7 @@ def _qtg_plot_graph(G, edges, vertex_size, title): _qtg_widgets.append(widget) -@_plt_handle_figure -def _plot_filter(filters, n, eigenvalues, sum, ax, **kwargs): +def _plot_filter(filters, n, eigenvalues, sum, title, save, ax, **kwargs): r"""Plot the spectral response of a filter bank, a set of graph filters. Parameters @@ -357,18 +356,27 @@ def _plot_filter(filters, n, eigenvalues, sum, ax, **kwargs): """ - G = filters.G - if eigenvalues is None: - eigenvalues = hasattr(G, '_e') + eigenvalues = hasattr(filters.G, '_e') + if sum is None: - sum = filters.Nf > 1 + sum = filters.n_filters > 1 + + if title is None: + title = repr(filters) + + _plt_plot_filter(filters, n=n, eigenvalues=eigenvalues, sum=sum, + title=title, save=save, ax=ax, **kwargs) + + +@_plt_handle_figure +def _plt_plot_filter(filters, n, eigenvalues, sum, ax, **kwargs): if eigenvalues: - for e in G.e: + for e in filters.G.e: ax.axvline(x=e, color=[0.9]*3, linewidth=1) - x = np.linspace(0, G.lmax, n) + x = np.linspace(0, filters.G.lmax, n) y = filters.evaluate(x).T ax.plot(x, y, **kwargs) @@ -377,8 +385,8 @@ def _plot_filter(filters, n, eigenvalues, sum, ax, **kwargs): if sum: ax.plot(x, np.sum(y**2, 1), 'k', **kwargs) - ax.set_xlabel("$\lambda$: laplacian's eigenvalues / graph frequencies") - ax.set_ylabel('$\hat{g}(\lambda)$: filter response') + ax.set_xlabel(r"$\lambda$: laplacian's eigenvalues / graph frequencies") + ax.set_ylabel(r'$\hat{g}(\lambda)$: filter response') def _plot_signal(G, signal, edges, vertex_size, highlight, colorbar, From 53398f588ba5db6c91279412d0c882a23de84f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 10 Apr 2018 04:14:55 +0200 Subject: [PATCH 389/392] string representation of graphs for print --- doc/tutorials/optimization.rst | 2 +- doc/tutorials/pyramid.rst | 2 +- pygsp/graphs/airfoil.py | 2 +- pygsp/graphs/barabasialbert.py | 11 +++++-- pygsp/graphs/comet.py | 9 ++++-- pygsp/graphs/community.py | 36 ++++++++++++++++++--- pygsp/graphs/davidsensornet.py | 11 +++++-- pygsp/graphs/erdosrenyi.py | 7 ++-- pygsp/graphs/fullconnected.py | 3 +- pygsp/graphs/graph.py | 39 ++++++++++++++--------- pygsp/graphs/grid2d.py | 8 ++++- pygsp/graphs/logo.py | 3 +- pygsp/graphs/lowstretchtree.py | 6 +++- pygsp/graphs/minnesota.py | 19 ++++++----- pygsp/graphs/nngraphs/bunny.py | 2 +- pygsp/graphs/nngraphs/cube.py | 15 +++++++-- pygsp/graphs/nngraphs/grid2dimgpatches.py | 21 ++++++------ pygsp/graphs/nngraphs/imgpatches.py | 10 ++++-- pygsp/graphs/nngraphs/nngraph.py | 26 +++++++++------ pygsp/graphs/nngraphs/sphere.py | 18 ++++++++--- pygsp/graphs/nngraphs/twomoons.py | 39 +++++++++++++++-------- pygsp/graphs/path.py | 3 +- pygsp/graphs/randomregular.py | 16 ++++++---- pygsp/graphs/randomring.py | 8 +++-- pygsp/graphs/ring.py | 11 ++++--- pygsp/graphs/sensor.py | 28 +++++++++------- pygsp/graphs/stochasticblockmodel.py | 37 +++++++++++++++++---- pygsp/graphs/swissroll.py | 22 +++++++++++-- pygsp/graphs/torus.py | 10 ++++-- pygsp/plotting.py | 6 ++-- pygsp/reduction.py | 6 ++-- pygsp/tests/test_graphs.py | 4 +-- 32 files changed, 301 insertions(+), 139 deletions(-) diff --git a/doc/tutorials/optimization.rst b/doc/tutorials/optimization.rst index ef3d4f82..b4110484 100644 --- a/doc/tutorials/optimization.rst +++ b/doc/tutorials/optimization.rst @@ -16,7 +16,7 @@ This tutorial focuses on the problem of recovering a label signal on a graph fro >>> from pygsp import graphs, plotting >>> >>> # Create a random sensor graph - >>> G = graphs.Sensor(N=256, distribute=True, seed=42) + >>> G = graphs.Sensor(N=256, distributed=True, seed=42) >>> G.compute_fourier_basis() >>> >>> # Create label signal diff --git a/doc/tutorials/pyramid.rst b/doc/tutorials/pyramid.rst index e21c82d9..8bdd1cf1 100644 --- a/doc/tutorials/pyramid.rst +++ b/doc/tutorials/pyramid.rst @@ -10,7 +10,7 @@ To start open a python shell (IPython is recommended here) and import the requir For this demo we will be using a sensor graph with 512 nodes. ->>> G = graphs.Sensor(512, distribute=True) +>>> G = graphs.Sensor(512, distributed=True) >>> G.compute_fourier_basis() The function graph_multiresolution computes the graph pyramid for you: diff --git a/pygsp/graphs/airfoil.py b/pygsp/graphs/airfoil.py index 34eb2234..7dc17631 100644 --- a/pygsp/graphs/airfoil.py +++ b/pygsp/graphs/airfoil.py @@ -35,4 +35,4 @@ def __init__(self, **kwargs): -1e-4, 1.01*data['y'].max()])} super(Airfoil, self).__init__(W=W, coords=coords, plotting=plotting, - gtype='Airfoil', **kwargs) + **kwargs) diff --git a/pygsp/graphs/barabasialbert.py b/pygsp/graphs/barabasialbert.py index c907bad9..8a61d077 100644 --- a/pygsp/graphs/barabasialbert.py +++ b/pygsp/graphs/barabasialbert.py @@ -42,9 +42,14 @@ class BarabasiAlbert(Graph): """ def __init__(self, N=1000, m0=1, m=1, seed=None, **kwargs): + if m > m0: raise ValueError('Parameter m cannot be above parameter m0.') + self.m0 = m0 + self.m = m + self.seed = seed + W = sparse.lil_matrix((N, N)) rs = np.random.RandomState(seed) @@ -58,5 +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, gtype=u"Barabasi-Albert", **kwargs) + super(BarabasiAlbert, self).__init__(W=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 7cd47d6f..f67be4e5 100644 --- a/pygsp/graphs/comet.py +++ b/pygsp/graphs/comet.py @@ -30,6 +30,8 @@ class Comet(Graph): def __init__(self, N=32, k=12, **kwargs): + self.k = k + # Create weighted adjacency matrix i_inds = np.concatenate((np.zeros((k)), np.arange(k) + 1, np.arange(k, N - 1), @@ -47,12 +49,13 @@ def __init__(self, N=32, k=12, **kwargs): tmpcoords[1:k + 1, 1] = np.sin(inds*2*np.pi/k) tmpcoords[k + 1:, 0] = np.arange(1, N - k) + 1 - self.N = N - self.k = k plotting = {"limits": np.array([-2, np.max(tmpcoords[:, 0]), np.min(tmpcoords[:, 1]), np.max(tmpcoords[:, 1])])} - super(Comet, self).__init__(W=W, coords=tmpcoords, gtype='Comet', + super(Comet, self).__init__(W=W, coords=tmpcoords, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(k=self.k) diff --git a/pygsp/graphs/community.py b/pygsp/graphs/community.py index af9f586d..04fc4c92 100644 --- a/pygsp/graphs/community.py +++ b/pygsp/graphs/community.py @@ -62,7 +62,7 @@ def __init__(self, N=256, Nc=None, min_comm=None, - min_deg=0, + min_deg=None, comm_sizes=None, size_ratio=1, world_density=None, @@ -76,12 +76,25 @@ def __init__(self, Nc = int(round(np.sqrt(N) / 2)) if min_comm is None: min_comm = int(round(N / (3 * Nc))) + if min_deg is not None: + raise NotImplementedError if world_density is None: world_density = 1 / N if not 0 <= world_density <= 1: raise ValueError('World density should be in [0, 1].') if epsilon is None: epsilon = np.sqrt(2 * np.sqrt(N)) / 2 + + self.Nc = Nc + self.min_comm = min_comm + self.comm_sizes = comm_sizes + self.size_ratio = size_ratio + self.world_density = world_density + self.comm_density = comm_density + self.k_neigh = k_neigh + self.epsilon = epsilon + self.seed = seed + rs = np.random.RandomState(seed) self.logger = utils.build_logger(__name__) @@ -113,8 +126,8 @@ def __init__(self, # Intra-community edges construction # if comm_density is not None: # random picking edges following the community density (same for all communities) - comm_density = float(comm_density) - comm_density = comm_density if 0. <= comm_density <= 1. else 0.1 + if not 0 <= comm_density <= 1: + raise ValueError('comm_density should be between 0 and 1.') info['comm_density'] = comm_density self.logger.info('Constructed using community density = {}'.format(comm_density)) elif k_neigh is not None: @@ -223,4 +236,19 @@ def __init__(self, for key, value in {'Nc': Nc, 'info': info}.items(): setattr(self, key, value) - super(Community, self).__init__(W=W, gtype='Community', coords=coords, **kwargs) + super(Community, self).__init__(W=W, coords=coords, **kwargs) + + def _get_extra_repr(self): + attrs = {'Nc': self.Nc, + 'min_comm': self.min_comm, + 'comm_sizes': self.comm_sizes, + 'size_ratio': '{:.2f}'.format(self.size_ratio), + 'world_density': '{:.2f}'.format(self.world_density)} + if self.comm_density is not None: + attrs['comm_density'] = '{:.2f}'.format(self.comm_density) + elif self.k_neigh is not None: + attrs['k_neigh'] = self.k_neigh + else: + attrs['epsilon'] = '{:.2f}'.format(self.epsilon) + attrs['seed'] = self.seed + return attrs diff --git a/pygsp/graphs/davidsensornet.py b/pygsp/graphs/davidsensornet.py index 5ab40ebb..b99d425e 100644 --- a/pygsp/graphs/davidsensornet.py +++ b/pygsp/graphs/davidsensornet.py @@ -29,6 +29,9 @@ class DavidSensorNet(Graph): """ def __init__(self, N=64, seed=None, **kwargs): + + self.seed = seed + if N == 64: data = utils.loadmat('pointclouds/david64') assert data['N'][0, 0] == N @@ -54,6 +57,8 @@ def __init__(self, N=64, seed=None, **kwargs): plotting = {"limits": [0, 1, 0, 1]} - super(DavidSensorNet, self).__init__(W=W, gtype='davidsensornet', - coords=coords, plotting=plotting, - **kwargs) + super(DavidSensorNet, self).__init__(W=W, coords=coords, + plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(seed=self.seed) diff --git a/pygsp/graphs/erdosrenyi.py b/pygsp/graphs/erdosrenyi.py index 40e016d9..52274000 100644 --- a/pygsp/graphs/erdosrenyi.py +++ b/pygsp/graphs/erdosrenyi.py @@ -23,7 +23,7 @@ class ErdosRenyi(StochasticBlockModel): Allow self loops if True (default is False). connected : bool Force the graph to be connected (default is False). - max_iter : int + n_try : int Maximum number of trials to get a connected graph (default is 10). seed : int Seed for the random number generator (for reproducible graphs). @@ -40,13 +40,12 @@ class ErdosRenyi(StochasticBlockModel): """ def __init__(self, N=100, p=0.1, directed=False, self_loops=False, - connected=False, max_iter=10, seed=None, **kwargs): + connected=False, n_try=10, seed=None, **kwargs): super(ErdosRenyi, self).__init__(N=N, k=1, p=p, directed=directed, self_loops=self_loops, connected=connected, - max_iter=max_iter, + n_try=n_try, seed=seed, **kwargs) - self.gtype = u"Erdös Renyi" diff --git a/pygsp/graphs/fullconnected.py b/pygsp/graphs/fullconnected.py index 9e89a05f..8e664ffd 100644 --- a/pygsp/graphs/fullconnected.py +++ b/pygsp/graphs/fullconnected.py @@ -31,5 +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, gtype='full', - plotting=plotting, **kwargs) + super(FullConnected, self).__init__(W=W, plotting=plotting, **kwargs) diff --git a/pygsp/graphs/graph.py b/pygsp/graphs/graph.py index e9ffc287..1fc89c3a 100644 --- a/pygsp/graphs/graph.py +++ b/pygsp/graphs/graph.py @@ -20,9 +20,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): ---------- W : sparse matrix or ndarray The weight matrix which encodes the graph. - gtype : string - Graph type, a free-form string to help us recognize the kind of graph - we are dealing with (default is 'unknown'). lap_type : 'combinatorial', 'normalized' The type of Laplacian to be computed by :func:`compute_laplacian` (default is 'combinatorial'). @@ -43,9 +40,6 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): 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. - gtype : string - the graph type is a short description of the graph object designed to - help sorting the graphs. L : sparse matrix the graph Laplacian, an N-by-N matrix computed from W. lap_type : 'normalized', 'combinatorial' @@ -63,8 +57,7 @@ class Graph(fourier.GraphFourier, difference.GraphDifference): """ - def __init__(self, W, gtype='unknown', lap_type='combinatorial', - coords=None, plotting={}): + def __init__(self, W, lap_type='combinatorial', coords=None, plotting={}): self.logger = utils.build_logger(__name__) @@ -82,7 +75,7 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', # real number of edges. Problematic when e.g. plotting. self.W.eliminate_zeros() - self.N = W.shape[0] + self.n_nodes = W.shape[0] # TODO: why would we ever want this? # For large matrices it slows the graph construction by a factor 100. @@ -91,16 +84,14 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', # Don't count edges two times if undirected. # Be consistent with the size of the differential operator. if self.is_directed(): - self.Ne = self.W.nnz + self.n_edges = self.W.nnz else: diagonal = np.count_nonzero(self.W.diagonal()) off_diagonal = self.W.nnz - diagonal - self.Ne = off_diagonal // 2 + diagonal + self.n_edges = off_diagonal // 2 + diagonal self.check_weights() - self.gtype = gtype - self.compute_laplacian(lap_type) if coords is not None: @@ -113,6 +104,24 @@ def __init__(self, W, gtype='unknown', lap_type='combinatorial', 'edge_style': '-'} self.plotting.update(plotting) + # TODO: kept for backward compatibility. + self.Ne = self.n_edges + self.N = self.n_nodes + + def _get_extra_repr(self): + return dict() + + def __repr__(self, limit=None): + s = '' + for attr in ['n_nodes', 'n_edges']: + s += '{}={}, '.format(attr, getattr(self, attr)) + for i, (key, value) in enumerate(self._get_extra_repr().items()): + if (limit is not None) and (i == limit - 2): + s += '..., ' + break + s += '{}={}, '.format(key, value) + return '{}({})'.format(self.__class__.__name__, s[:-2]) + def check_weights(self): r"""Check the characteristics of the weights matrix. @@ -284,7 +293,7 @@ def subgraph(self, ind): # N = len(ind) # Assigned but never used sub_W = self.W.tocsr()[ind, :].tocsc()[:, ind] - return Graph(sub_W, gtype="sub-{}".format(self.gtype)) + return Graph(sub_W) def is_connected(self, recompute=False): r"""Check the strong connectivity of the graph (cached). @@ -506,7 +515,7 @@ def compute_laplacian(self, lap_type='combinatorial'): elif lap_type == 'normalized': d = np.power(self.dw, -0.5) D = sparse.diags(np.ravel(d), 0).tocsc() - self.L = sparse.identity(self.N) - D * self.W * D + self.L = sparse.identity(self.n_nodes) - D * self.W * D @property diff --git a/pygsp/graphs/grid2d.py b/pygsp/graphs/grid2d.py index 681e9916..f0bd598e 100644 --- a/pygsp/graphs/grid2d.py +++ b/pygsp/graphs/grid2d.py @@ -32,6 +32,9 @@ def __init__(self, N1=16, N2=None, **kwargs): if N2 is None: N2 = N1 + self.N1 = N1 + self.N2 = N2 + N = N1 * N2 # Filling up the weight matrix this way is faster than @@ -54,5 +57,8 @@ 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, gtype='2d-grid', coords=coords, + super(Grid2d, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(N1=self.N1, N2=self.N2) diff --git a/pygsp/graphs/logo.py b/pygsp/graphs/logo.py index 384fb6d0..a53d7147 100644 --- a/pygsp/graphs/logo.py +++ b/pygsp/graphs/logo.py @@ -30,5 +30,4 @@ def __init__(self, **kwargs): plotting = {"limits": np.array([0, 640, -400, 0])} super(Logo, self).__init__(W=data['W'], coords=data['coords'], - gtype='LogoGSP', plotting=plotting, - **kwargs) + plotting=plotting, **kwargs) diff --git a/pygsp/graphs/lowstretchtree.py b/pygsp/graphs/lowstretchtree.py index 8de7f385..77ad6e1f 100644 --- a/pygsp/graphs/lowstretchtree.py +++ b/pygsp/graphs/lowstretchtree.py @@ -30,6 +30,8 @@ class LowStretchTree(Graph): def __init__(self, k=6, **kwargs): + self.k = k + XCoords = np.array([1, 2, 1, 2]) YCoords = np.array([1, 1, 2, 2]) @@ -68,5 +70,7 @@ def __init__(self, k=6, **kwargs): super(LowStretchTree, self).__init__(W=W, coords=coords, plotting=plotting, - gtype="low stretch tree", **kwargs) + + def _get_extra_repr(self): + return dict(k=self.k) diff --git a/pygsp/graphs/minnesota.py b/pygsp/graphs/minnesota.py index 343ef6de..c7bd7496 100644 --- a/pygsp/graphs/minnesota.py +++ b/pygsp/graphs/minnesota.py @@ -12,7 +12,7 @@ class Minnesota(Graph): Parameters ---------- - connect : bool + connected : bool If True, the adjacency matrix is adjusted so that all edge weights are equal to 1, and the graph is connected. Set to False to get the original disconnected graph. @@ -31,7 +31,9 @@ class Minnesota(Graph): """ - def __init__(self, connect=True, **kwargs): + def __init__(self, connected=True, **kwargs): + + self.connected = connected data = utils.loadmat('pointclouds/minnesota') self.labels = data['labels'] @@ -40,7 +42,7 @@ def __init__(self, connect=True, **kwargs): plotting = {"limits": np.array([-98, -89, 43, 50]), "vertex_size": 40} - if connect: + if connected: # Missing edges needed to connect the graph. A = sparse.lil_matrix(A) @@ -51,11 +53,8 @@ def __init__(self, connect=True, **kwargs): # Binarize: 8 entries are equal to 2 instead of 1. A = (A > 0).astype(bool) - gtype = 'minnesota' - - else: - - gtype = 'minnesota-disconnected' - - super(Minnesota, self).__init__(W=A, coords=data['xy'], gtype=gtype, + super(Minnesota, self).__init__(W=A, coords=data['xy'], plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(connected=self.connected) diff --git a/pygsp/graphs/nngraphs/bunny.py b/pygsp/graphs/nngraphs/bunny.py index e1234f86..357eb149 100644 --- a/pygsp/graphs/nngraphs/bunny.py +++ b/pygsp/graphs/nngraphs/bunny.py @@ -36,4 +36,4 @@ def __init__(self, **kwargs): super(Bunny, self).__init__(Xin=data['bunny'], epsilon=0.2, NNtype='radius', plotting=plotting, - gtype='Bunny', **kwargs) + **kwargs) diff --git a/pygsp/graphs/nngraphs/cube.py b/pygsp/graphs/nngraphs/cube.py index fe66992f..9ae4b9b7 100644 --- a/pygsp/graphs/nngraphs/cube.py +++ b/pygsp/graphs/nngraphs/cube.py @@ -46,10 +46,11 @@ def __init__(self, self.nb_pts = nb_pts self.nb_dim = nb_dim self.sampling = sampling + self.seed = seed rs = np.random.RandomState(seed) if self.nb_dim > 3: - raise NotImplementedError("Dimension > 3 not supported yet !") + raise NotImplementedError("Dimension > 3 not supported yet!") if self.sampling == "random": if self.nb_dim == 2: @@ -88,5 +89,13 @@ def __init__(self, 'distance': 7, } - super(Cube, self).__init__(Xin=pts, k=10, gtype="Cube", - plotting=plotting, **kwargs) + super(Cube, self).__init__(Xin=pts, k=10, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + attrs = {'radius': '{:.2f}'.format(self.radius), + 'nb_pts': self.nb_pts, + 'nb_dim': self.nb_dim, + 'sampling': self.sampling, + 'seed': self.seed} + attrs.update(super(Cube, self)._get_extra_repr()) + return attrs diff --git a/pygsp/graphs/nngraphs/grid2dimgpatches.py b/pygsp/graphs/nngraphs/grid2dimgpatches.py index 56b895d5..cb3d5fa2 100644 --- a/pygsp/graphs/nngraphs/grid2dimgpatches.py +++ b/pygsp/graphs/nngraphs/grid2dimgpatches.py @@ -31,12 +31,15 @@ class Grid2dImgPatches(Graph): def __init__(self, img, aggregate=lambda Wp, Wg: Wp + Wg, **kwargs): - Gg = Grid2d(img.shape[0], img.shape[1]) - Gp = ImgPatches(img, **kwargs) - - gtype = '{}_{}'.format(Gg.gtype, Gp.gtype) - - super(Grid2dImgPatches, self).__init__(W=aggregate(Gp.W, Gg.W), - gtype=gtype, - coords=Gg.coords, - plotting=Gg.plotting) + self.Gg = Grid2d(img.shape[0], img.shape[1]) + self.Gp = ImgPatches(img, **kwargs) + + W = aggregate(self.Gp.W, self.Gg.W) + super(Grid2dImgPatches, self).__init__(W=W, + coords=self.Gg.coords, + plotting=self.Gg.plotting) + + def _get_extra_repr(self): + attrs = self.Gg._get_extra_repr() + attrs.update(self.Gp._get_extra_repr()) + return attrs diff --git a/pygsp/graphs/nngraphs/imgpatches.py b/pygsp/graphs/nngraphs/imgpatches.py index ecdb33ba..8a5aa244 100644 --- a/pygsp/graphs/nngraphs/imgpatches.py +++ b/pygsp/graphs/nngraphs/imgpatches.py @@ -49,6 +49,7 @@ class ImgPatches(NNGraph): def __init__(self, img, patch_shape=(3, 3), **kwargs): self.img = img + self.patch_shape = patch_shape try: h, w, d = img.shape @@ -90,6 +91,9 @@ def __init__(self, img, patch_shape=(3, 3), **kwargs): patches = skimage.util.view_as_windows(img, window_shape=window_shape) patches = patches.reshape((h * w, r * c * d)) - super(ImgPatches, self).__init__(patches, - gtype='patch-graph', - **kwargs) + super(ImgPatches, self).__init__(patches, **kwargs) + + def _get_extra_repr(self): + attrs = dict(patch_shape=self.patch_shape) + attrs.update(super(ImgPatches, self)._get_extra_repr()) + return attrs diff --git a/pygsp/graphs/nngraphs/nngraph.py b/pygsp/graphs/nngraphs/nngraph.py index 397af4e7..3415d773 100644 --- a/pygsp/graphs/nngraphs/nngraph.py +++ b/pygsp/graphs/nngraphs/nngraph.py @@ -46,8 +46,6 @@ class NNGraph(Graph): Width parameter of the similarity kernel (default is 0.1) epsilon : float, optional Radius for the epsilon-neighborhood search (default is 0.01) - gtype : string, optional - The type of graph (default is 'nearest neighbors') plotting : dict, optional Dictionary of plotting parameters. See :obj:`pygsp.plotting`. (default is {}) @@ -75,7 +73,7 @@ class NNGraph(Graph): """ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, - rescale=True, k=10, sigma=0.1, epsilon=0.01, gtype=None, + rescale=True, k=10, sigma=0.1, epsilon=0.01, plotting={}, symmetrize_type='average', dist_type='euclidean', order=0, **kwargs): @@ -87,13 +85,9 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, self.k = k self.sigma = sigma self.epsilon = epsilon - - if gtype is None: - gtype = 'nearest neighbors' - else: - gtype = '{}, NNGraph'.format(gtype) - self.symmetrize_type = symmetrize_type + self.dist_type = dist_type + self.order = order N, d = np.shape(self.Xin) Xout = self.Xin @@ -177,5 +171,17 @@ def __init__(self, Xin, NNtype='knn', use_flann=False, center=True, # np.abs(W - W.T).sum() is as costly as the symmetrization itself. W = utils.symmetrize(W, method=symmetrize_type) - super(NNGraph, self).__init__(W=W, gtype=gtype, plotting=plotting, + super(NNGraph, self).__init__(W=W, plotting=plotting, coords=Xout, **kwargs) + + def _get_extra_repr(self): + return {'NNtype': self.NNtype, + 'use_flann': self.use_flann, + '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} diff --git a/pygsp/graphs/nngraphs/sphere.py b/pygsp/graphs/nngraphs/sphere.py index 111a5b41..b459401f 100644 --- a/pygsp/graphs/nngraphs/sphere.py +++ b/pygsp/graphs/nngraphs/sphere.py @@ -10,13 +10,13 @@ class Sphere(NNGraph): Parameters ---------- - radius : flaot + radius : float Radius of the sphere (default = 1) nb_pts : int Number of vertices (default = 300) nb_dim : int Dimension (default = 3) - sampling : sting + sampling : string Variance of the distance kernel (default = 'random') (Can now only be 'random') seed : int @@ -46,6 +46,7 @@ def __init__(self, self.nb_pts = nb_pts self.nb_dim = nb_dim self.sampling = sampling + self.seed = seed if self.sampling == 'random': @@ -57,11 +58,20 @@ def __init__(self, else: - raise ValueError('Unknown sampling!') + raise ValueError('Unknown sampling {}'.format(sampling)) plotting = { 'vertex_size': 80, } - super(Sphere, self).__init__(Xin=pts, gtype='Sphere', k=10, + super(Sphere, self).__init__(Xin=pts, k=10, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + attrs = {'radius': '{:.2f}'.format(self.radius), + 'nb_pts': self.nb_pts, + 'nb_dim': self.nb_dim, + 'sampling': self.sampling, + 'seed': self.seed} + attrs.update(super(Sphere, self)._get_extra_repr()) + return attrs diff --git a/pygsp/graphs/nngraphs/twomoons.py b/pygsp/graphs/nngraphs/twomoons.py index 0b142e3e..ff42ebde 100644 --- a/pygsp/graphs/nngraphs/twomoons.py +++ b/pygsp/graphs/nngraphs/twomoons.py @@ -28,8 +28,8 @@ class TwoMoons(NNGraph): Variance of the data (do not set it too high or you won't see anything) (default = 0.05) Only valid for moontype == 'synthesized'. - d : float - Distance of the two moons (default = 0.5) + distance : float + Distance between the two moons (default = 0.5) Only valid for moontype == 'synthesized'. seed : int Seed for the random number generator (for reproducible graphs). @@ -44,7 +44,7 @@ class TwoMoons(NNGraph): """ - def _create_arc_moon(self, N, sigmad, d, number, seed): + def _create_arc_moon(self, N, sigmad, distance, number, seed): rs = np.random.RandomState(seed) phi = rs.rand(N, 1) * np.pi r = 1 @@ -56,30 +56,34 @@ def _create_arc_moon(self, N, sigmad, d, number, seed): if number == 1: moonx = np.cos(phi) * r + bx + 0.5 - moony = -np.sin(phi) * r + by - (d - 1)/2. + moony = -np.sin(phi) * r + by - (distance - 1)/2. elif number == 2: moonx = np.cos(phi) * r + bx - 0.5 - moony = np.sin(phi) * r + by + (d - 1)/2. + moony = np.sin(phi) * r + by + (distance - 1)/2. return np.concatenate((moonx, moony), axis=1) def __init__(self, moontype='standard', dim=2, sigmag=0.05, - N=400, sigmad=0.07, d=0.5, seed=None, **kwargs): + N=400, sigmad=0.07, distance=0.5, seed=None, **kwargs): + + self.moontype = moontype + self.dim = dim + self.sigmag = sigmag + self.sigmad = sigmad + self.distance = distance + self.seed = seed if moontype == 'standard': - gtype = 'Two Moons standard' N1, N2 = 1000, 1000 data = utils.loadmat('pointclouds/two_moons') Xin = data['features'][:dim].T elif moontype == 'synthesized': - gtype = 'Two Moons synthesized' - N1 = N // 2 N2 = N - N1 - coords1 = self._create_arc_moon(N1, sigmad, d, 1, seed) - coords2 = self._create_arc_moon(N2, sigmad, d, 2, seed) + coords1 = self._create_arc_moon(N1, sigmad, distance, 1, seed) + coords2 = self._create_arc_moon(N2, sigmad, distance, 2, seed) Xin = np.concatenate((coords1, coords2)) @@ -93,5 +97,14 @@ def __init__(self, moontype='standard', dim=2, sigmag=0.05, } super(TwoMoons, self).__init__(Xin=Xin, sigma=sigmag, k=5, - plotting=plotting, gtype=gtype, - **kwargs) + 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), + 'distance': '{:.2f}'.format(self.distance), + 'seed': self.seed} + attrs.update(super(TwoMoons, self)._get_extra_repr()) + return attrs diff --git a/pygsp/graphs/path.py b/pygsp/graphs/path.py index e18f00a0..0efc7b7a 100644 --- a/pygsp/graphs/path.py +++ b/pygsp/graphs/path.py @@ -36,7 +36,6 @@ def __init__(self, N=16, **kwargs): W = sparse.csc_matrix((weights, (inds_i, inds_j)), shape=(N, N)) plotting = {"limits": np.array([-1, N, -1, 1])} - super(Path, self).__init__(W=W, gtype='path', - plotting=plotting, **kwargs) + super(Path, self).__init__(W=W, plotting=plotting, **kwargs) self.set_coordinates('line2D') diff --git a/pygsp/graphs/randomregular.py b/pygsp/graphs/randomregular.py index 3cdd4a16..3da5c53c 100644 --- a/pygsp/graphs/randomregular.py +++ b/pygsp/graphs/randomregular.py @@ -20,7 +20,7 @@ class RandomRegular(Graph): Number of nodes (default is 64) k : int Number of connections, or degree, of each node (default is 6) - maxIter : int + max_iter : int Maximum number of iterations (default is 10) seed : int Seed for the random number generator (for reproducible graphs). @@ -47,8 +47,11 @@ class RandomRegular(Graph): """ - def __init__(self, N=64, k=6, maxIter=10, seed=None, **kwargs): + def __init__(self, N=64, k=6, max_iter=10, seed=None, **kwargs): + self.k = k + self.max_iter = max_iter + self.seed = seed self.logger = utils.build_logger(__name__) @@ -67,10 +70,9 @@ def __init__(self, N=64, k=6, maxIter=10, seed=None, **kwargs): edgesTested = 0 repetition = 1 - while np.size(U) and repetition < maxIter: + while np.size(U) and repetition < max_iter: edgesTested += 1 - # print(progess) if edgesTested % 5000 == 0: self.logger.debug("createRandRegGraph() progress: edges= " "{}/{}.".format(edgesTested, N*k/2)) @@ -98,8 +100,7 @@ def __init__(self, N=64, k=6, maxIter=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, gtype="random_regular", - **kwargs) + super(RandomRegular, self).__init__(W=A, **kwargs) self.is_regular() @@ -133,3 +134,6 @@ def is_regular(self): if warn: self.logger.warning('{}.'.format(msg[:-1])) + + def _get_extra_repr(self): + return dict(k=self.k, seed=self.seed) diff --git a/pygsp/graphs/randomring.py b/pygsp/graphs/randomring.py index 2f217e2f..e9b12ae4 100644 --- a/pygsp/graphs/randomring.py +++ b/pygsp/graphs/randomring.py @@ -31,6 +31,8 @@ class RandomRing(Graph): 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) @@ -49,6 +51,8 @@ def __init__(self, N=64, seed=None, **kwargs): coords = np.stack([np.cos(angle), np.sin(angle)], axis=1) plotting = {'limits': np.array([-1, 1, -1, 1])} - super(RandomRing, self).__init__(W=W, gtype='random-ring', - coords=coords, plotting=plotting, + super(RandomRing, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(seed=self.seed) diff --git a/pygsp/graphs/ring.py b/pygsp/graphs/ring.py index e28fbed3..832cb98c 100644 --- a/pygsp/graphs/ring.py +++ b/pygsp/graphs/ring.py @@ -28,6 +28,8 @@ class Ring(Graph): def __init__(self, N=64, k=1, **kwargs): + self.k = k + if 2*k > N: raise ValueError('Too many neighbors requested.') @@ -55,10 +57,9 @@ def __init__(self, N=64, k=1, **kwargs): plotting = {'limits': np.array([-1, 1, -1, 1])} - gtype = 'ring' if k == 1 else 'k-ring' - self.k = k - - super(Ring, self).__init__(W=W, gtype=gtype, plotting=plotting, - **kwargs) + super(Ring, self).__init__(W=W, plotting=plotting, **kwargs) self.set_coordinates('ring2D') + + def _get_extra_repr(self): + return dict(k=self.k) diff --git a/pygsp/graphs/sensor.py b/pygsp/graphs/sensor.py index 409c8814..15e335b3 100644 --- a/pygsp/graphs/sensor.py +++ b/pygsp/graphs/sensor.py @@ -19,8 +19,8 @@ class Sensor(Graph): regular : bool Flag to fix the number of connections to nc (default = False) n_try : int - Number of attempt to create the graph (default = 50) - distribute : bool + Maximum number of trials to get a connected graph (default is 50). + distributed : bool To distribute the points more evenly (default = False) connected : bool To force the graph to be connected (default = True) @@ -38,19 +38,20 @@ class Sensor(Graph): """ def __init__(self, N=64, Nc=2, regular=False, n_try=50, - distribute=False, connected=True, seed=None, **kwargs): + distributed=False, connected=True, seed=None, **kwargs): self.Nc = Nc self.regular = regular self.n_try = n_try - self.distribute = distribute + self.distributed = distributed + self.connected = connected self.seed = seed self.logger = utils.build_logger(__name__) if connected: for x in range(self.n_try): - W, coords = self._create_weight_matrix(N, distribute, + W, coords = self._create_weight_matrix(N, distributed, regular, Nc) self.W = W @@ -60,16 +61,14 @@ def __init__(self, N=64, Nc=2, regular=False, n_try=50, elif x == self.n_try - 1: self.logger.warning('Graph is not connected.') else: - W, coords = self._create_weight_matrix(N, distribute, regular, Nc) + W, coords = self._create_weight_matrix(N, distributed, regular, Nc) W = sparse.lil_matrix(W) W = utils.symmetrize(W, method='average') - gtype = 'regular sensor' if self.regular else 'sensor' - plotting = {'limits': np.array([0, 1, 0, 1])} - super(Sensor, self).__init__(W=W, coords=coords, gtype=gtype, + super(Sensor, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) def _get_nc_connection(self, W, param_nc): @@ -88,13 +87,13 @@ def _get_nc_connection(self, W, param_nc): return W - def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): + def _create_weight_matrix(self, N, distributed, regular, param_Nc): XCoords = np.zeros((N, 1)) YCoords = np.zeros((N, 1)) rs = np.random.RandomState(self.seed) - if param_distribute: + if distributed: mdim = int(np.ceil(np.sqrt(N))) for i in range(mdim): for j in range(mdim): @@ -127,3 +126,10 @@ def _create_weight_matrix(self, N, param_distribute, regular, param_Nc): W = sparse.csc_matrix(W) return W, coords + + def _get_extra_repr(self): + return {'Nc': self.Nc, + 'regular': self.regular, + 'distributed': self.distributed, + 'connected': self.connected, + 'seed': self.seed} diff --git a/pygsp/graphs/stochasticblockmodel.py b/pygsp/graphs/stochasticblockmodel.py index 2d6e9514..f0310a2c 100644 --- a/pygsp/graphs/stochasticblockmodel.py +++ b/pygsp/graphs/stochasticblockmodel.py @@ -41,7 +41,7 @@ class StochasticBlockModel(Graph): Allow self loops if True (default is False). connected : bool Force the graph to be connected (default is False). - max_iter : int + n_try : int Maximum number of trials to get a connected graph (default is 10). seed : int Seed for the random number generator (for reproducible graphs). @@ -60,16 +60,25 @@ class StochasticBlockModel(Graph): def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, directed=False, self_loops=False, connected=False, - max_iter=10, seed=None, **kwargs): + n_try=10, seed=None, **kwargs): + + self.k = k + self.directed = directed + self.self_loops = self_loops + self.connected = connected + self.n_try = n_try + self.seed = seed rs = np.random.RandomState(seed) if z is None: z = rs.randint(0, k, N) z.sort() # Sort for nice spy plot of W, where blocks are apparent. + self.z = z if M is None: + self.p = p p = np.asarray(p) if p.size == 1: p = p * np.ones(k) @@ -79,6 +88,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) if q.size == 1: q = q * np.ones((k, k)) @@ -89,6 +99,8 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, M = q M.flat[::k+1] = p # edit the diagonal terms + self.M = M + if (M < 0).any() or (M > 1).any(): raise ValueError('Probabilities should be in [0, 1].') @@ -96,7 +108,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(max_iter): + for nb_iter in range(n_try): nb_row, nb_col = 0, 0 csr_data, csr_i, csr_j = [], [], [] @@ -124,13 +136,24 @@ def __init__(self, N=1024, k=5, z=None, M=None, p=0.7, q=None, self.W = W if self.is_connected(recompute=True): break - if nb_iter == max_iter - 1: + 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(max_iter)) + 'or the number of trials.'.format(n_try)) self.info = {'node_com': z, 'comm_sizes': np.bincount(z), 'world_rad': np.sqrt(N)} - gtype = 'StochasticBlockModel' - super(StochasticBlockModel, self).__init__(gtype=gtype, W=W, **kwargs) + super(StochasticBlockModel, self).__init__(W=W, **kwargs) + + def _get_extra_repr(self): + attrs = {'k': self.k} + if type(self.p) is float: + attrs['p'] = '{:.2f}'.format(self.p) + if type(self.q) is float: + attrs['q'] = '{:.2f}'.format(self.q) + attrs.update({'directed': self.directed, + 'self_loops': self.self_loops, + 'connected': self.connected, + 'seed': self.seed}) + return attrs diff --git a/pygsp/graphs/swissroll.py b/pygsp/graphs/swissroll.py index 260cabe9..93725a7c 100644 --- a/pygsp/graphs/swissroll.py +++ b/pygsp/graphs/swissroll.py @@ -49,6 +49,15 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, if s is None: s = np.sqrt(2. / N) + self.a = a + self.b = b + self.dim = dim + self.thresh = thresh + self.s = s + self.noise = noise + self.srtype = srtype + self.seed = seed + rs = np.random.RandomState(seed) y1 = rs.rand(N) y2 = rs.rand(N) @@ -83,8 +92,17 @@ def __init__(self, N=400, a=1, b=4, dim=3, thresh=1e-6, s=None, 'azimuth': -90, 'distance': 7, } - gtype = 'swiss roll {}'.format(srtype) super(SwissRoll, self).__init__(W=W, coords=coords.T, - plotting=plotting, gtype=gtype, + plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return {'a': self.a, + 'b': self.b, + 'dim': self.dim, + 'thresh': '{:.0e}'.format(self.thresh), + 's': '{:.2f}'.format(self.s), + 'noise': self.noise, + 'srtype': self.srtype, + 'seed': self.seed} diff --git a/pygsp/graphs/torus.py b/pygsp/graphs/torus.py index 87bcac45..6e9e9262 100644 --- a/pygsp/graphs/torus.py +++ b/pygsp/graphs/torus.py @@ -38,6 +38,9 @@ def __init__(self, Nv=16, Mv=None, **kwargs): if Mv is None: Mv = Nv + self.Nv = Nv + self.Mv = Mv + # Create weighted adjancency matrix K = 2 * Nv J = 2 * Mv @@ -83,13 +86,14 @@ def __init__(self, Nv=16, Mv=None, **kwargs): np.reshape(ytmp, (Mv*Nv, 1), order='F'), np.reshape(ztmp, (Mv*Nv, 1), order='F')), axis=1) - self.Nv = Nv - self.Mv = Nv plotting = { 'vertex_size': 60, 'limits': np.array([-2.5, 2.5, -2.5, 2.5, -2.5, 2.5]) } - super(Torus, self).__init__(W=W, gtype='Torus', coords=coords, + super(Torus, self).__init__(W=W, coords=coords, plotting=plotting, **kwargs) + + def _get_extra_repr(self): + return dict(Nv=self.Nv, Mv=self.Mv) diff --git a/pygsp/plotting.py b/pygsp/plotting.py index 0e999b43..4c88886b 100644 --- a/pygsp/plotting.py +++ b/pygsp/plotting.py @@ -192,7 +192,7 @@ def _plot_graph(G, edges, backend, vertex_size, title, save, ax): vertex_size = G.plotting['vertex_size'] if title is None: - title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + title = G.__repr__(limit=4) G = _handle_directed(G) @@ -466,7 +466,7 @@ def _plot_signal(G, signal, edges, vertex_size, highlight, colorbar, vertex_size = G.plotting['vertex_size'] if title is None: - title = u'{}\nG.N={} nodes, G.Ne={} edges'.format(G.gtype, G.N, G.Ne) + title = G.__repr__(limit=4) if limits is None: limits = [1.05*signal.min(), 1.05*signal.max()] @@ -663,7 +663,7 @@ def _plot_spectrogram(G, node_idx): spectr = (spectr.astype(float) - min_spec) / (max_spec - min_spec) w = qtg.GraphicsWindow() - w.setWindowTitle("Spectrogram of {}".format(G.gtype)) + w.setWindowTitle("Spectrogram of {}".format(G.__repr__(limit=4))) label = 'frequencies {}:{:.2f}:{:.2f}'.format(0, G.lmax/M, G.lmax) v = w.addPlot(labels={'bottom': 'nodes', 'left': label}) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 4b3ca11a..b1ca942f 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -56,7 +56,7 @@ def graph_sparsify(M, epsilon, maxiter=10): Examples -------- >>> from pygsp import reduction - >>> G = graphs.Sensor(256, Nc=20, distribute=True) + >>> G = graphs.Sensor(256, Nc=20, distributed=True) >>> epsilon = 0.4 >>> G2 = reduction.graph_sparsify(G, epsilon) @@ -362,7 +362,7 @@ def kron_reduction(G, ind): 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, - plotting=G.plotting, gtype='Kron reduction') + plotting=G.plotting) else: Gnew = Lnew @@ -712,7 +712,7 @@ def tree_multiresolution(G, Nlevel, reduction_method='resistance_distance', depths = depths/2. # Store new tree - Gtemp = graphs.Graph(new_W, coords=Gs[lev].coords[keep_inds], limits=G.limits, gtype='tree', root=new_root) + Gtemp = graphs.Graph(new_W, coords=Gs[lev].coords[keep_inds], limits=G.limits, root=new_root) #Gs[lev].copy_graph_attributes(Gtemp, False) if compute_full_eigen: diff --git a/pygsp/tests/test_graphs.py b/pygsp/tests/test_graphs.py index dc51fec5..1680defc 100644 --- a/pygsp/tests/test_graphs.py +++ b/pygsp/tests/test_graphs.py @@ -241,8 +241,8 @@ def test_minnesota(self): def test_sensor(self): graphs.Sensor(regular=True) graphs.Sensor(regular=False) - graphs.Sensor(distribute=True) - graphs.Sensor(distribute=False) + graphs.Sensor(distributed=True) + graphs.Sensor(distributed=False) graphs.Sensor(connected=True) graphs.Sensor(connected=False) From ef8823a410697d587227e5d2eaf376f903a577d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Defferrard?= Date: Tue, 10 Apr 2018 04:28:11 +0200 Subject: [PATCH 390/392] update history --- doc/history.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/history.rst b/doc/history.rst index 0637e0d5..0c1f671a 100644 --- a/doc/history.rst +++ b/doc/history.rst @@ -9,6 +9,7 @@ The plotting interface was updated to be more user-friendly. First, the documentation is now shown for filters.plot(), G.plot(), and co. Second, the API in the plotting library has been deprecated. That module is now mostly for implementation only. Finally, the following parameter names were changed: + * plot_name => title * plot_eigenvalues => eigenvalues * show_sum => sum @@ -16,6 +17,11 @@ implementation only. Finally, the following parameter names were changed: * npoints => n * save_as => save +Additional features: + +* print(graph) and print(filters) now show valuable information. +* Building a graph object is much faster. + 0.5.1 (2017-12-15) ------------------ From 548c35731debce56c9bda47409c4cc9bd1d7fde7 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 19 Apr 2018 18:12:43 -0400 Subject: [PATCH 391/392] Fixes bug preventing multiresolution --- pygsp/reduction.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index b1ca942f..8f530156 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -135,7 +135,7 @@ def graph_sparsify(M, epsilon, maxiter=10): sparserW = (sparserW + sparserW.T) / 2. Mnew = graphs.Graph(W=sparserW) - #M.copy_graph_attributes(Mnew) + M.copy_graph_attributes(Mnew) else: Mnew = sparse.lil_matrix(sparserL) From 9ecacf073317f950d162c075ef1ab9299de6ea95 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 19 Apr 2018 18:16:50 -0400 Subject: [PATCH 392/392] fix for mr --- pygsp/reduction.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pygsp/reduction.py b/pygsp/reduction.py index 8f530156..7b5ad50f 100644 --- a/pygsp/reduction.py +++ b/pygsp/reduction.py @@ -135,7 +135,7 @@ def graph_sparsify(M, epsilon, maxiter=10): sparserW = (sparserW + sparserW.T) / 2. Mnew = graphs.Graph(W=sparserW) - M.copy_graph_attributes(Mnew) + #M.copy_graph_attributes(Mnew) else: Mnew = sparse.lil_matrix(sparserL) @@ -276,7 +276,9 @@ def graph_multiresolution(G, levels, sparsify=True, sparsify_eps=None, raise NotImplementedError('Unknown graph reduction method.') if sparsify and Gs[i+1].N > 2: + coords = Gs[i+1].coords Gs[i+1] = graph_sparsify(Gs[i+1], min(max(sparsify_eps, 2. / np.sqrt(Gs[i+1].N)), 1.)) + Gs[i+1].coords = coords # TODO : Make in place modifications instead! if compute_full_eigen: