diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 0cb5812..70a4e53 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -33,7 +33,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - py-version: ["3.10"] # 3.11 breaks for scikit-network + py-version: ["3.11"] steps: - uses: actions/checkout@v4 diff --git a/bw_graph_tools/graph_traversal_utils.py b/bw_graph_tools/graph_traversal_utils.py index f6cd118..e07d56f 100644 --- a/bw_graph_tools/graph_traversal_utils.py +++ b/bw_graph_tools/graph_traversal_utils.py @@ -2,9 +2,9 @@ from bw2calc import LCA from scipy import sparse -import sknetwork as skn from .matrix_tools import to_normalized_adjacency_matrix +from .shortest_path import get_shortest_path try: import bw2data as bd @@ -29,7 +29,7 @@ def get_path_from_matrix( ``algorithm`` should be either ``BF`` (Bellman-Ford) or ``J`` (Johnson). Dijkstra is not recommended as we have negative weights. Returns a list like ``[source, int, int, int, target]``.""" - return skn.path.get_shortest_path( + return get_shortest_path( adjacency=to_normalized_adjacency_matrix(matrix=matrix), sources=source, targets=target, @@ -48,7 +48,7 @@ def path_as_brightway_objects( lca = LCA({source_node: 1, target_node: 1}) lca.lci() - path = skn.path.get_shortest_path( + path = get_shortest_path( adjacency=to_normalized_adjacency_matrix(matrix=lca.technosphere_mm.matrix), sources=lca.activity_dict[source_node.id], targets=lca.activity_dict[target_node.id], diff --git a/bw_graph_tools/shortest_path.py b/bw_graph_tools/shortest_path.py new file mode 100644 index 0000000..150110d --- /dev/null +++ b/bw_graph_tools/shortest_path.py @@ -0,0 +1,206 @@ +""" +Created on November 12, 2019 +@author: Quentin Lutz +From scikit-network version 0.30 + +BSD License + +Copyright (c) 2018, Scikit-network Developers +Bertrand Charpentier +Thomas Bonald +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. + +""" +from functools import partial +from multiprocessing import Pool +from typing import Optional, Union, Iterable + +import numpy as np +from scipy import sparse + + +def get_distances(adjacency: sparse.csr_matrix, sources: Optional[Union[int, Iterable]] = None, method: str = 'D', + return_predecessors: bool = False, unweighted: bool = False, n_jobs: Optional[int] = None): + """Compute distances between nodes. + + * Graphs + * Digraphs + + + Based on SciPy (scipy.sparse.csgraph.shortest_path) + + Parameters + ---------- + adjacency : + The adjacency matrix of the graph + sources : + If specified, only compute the paths for the points at the given indices. Will not work with ``method =='FW'``. + method : + The method to be used. + + * ``'D'`` (Dijkstra), + * ``'BF'`` (Bellman-Ford), + * ``'J'`` (Johnson). + return_predecessors : + If ``True``, the size predecessor matrix is returned + unweighted : + If ``True``, the weights of the edges are ignored + n_jobs : + If an integer value is given, denotes the number of workers to use (-1 means the maximum number will be used). + If ``None``, no parallel computations are made. + + Returns + ------- + dist_matrix : np.ndarray + Matrix of distances between nodes. ``dist_matrix[i,j]`` gives the shortest + distance from the ``i``-th source to node ``j`` in the graph (infinite if no path exists + from the ``i``-th source to node ``j``). + predecessors : np.ndarray, optional + Returned only if ``return_predecessors == True``. The matrix of predecessors, which can be used to reconstruct + the shortest paths. Row ``i`` of the predecessor matrix contains information on the shortest paths from the + ``i``-th source: each entry ``predecessors[i, j]`` gives the index of the previous node in the path from + the ``i``-th source to node ``j`` (-1 if no path exists from the ``i``-th source to node ``j``). + + """ + n_jobs, directed = 1, True + if method == 'FW' and n_jobs != 1: + raise ValueError('The Floyd-Warshall algorithm cannot be used with parallel computations.') + if sources is None: + sources = np.arange(adjacency.shape[0]) + elif np.issubdtype(type(sources), np.integer): + sources = np.array([sources]) + n = len(sources) + local_function = partial(sparse.csgraph.shortest_path, + adjacency, method, directed, return_predecessors, unweighted, False) + if n_jobs == 1 or n == 1: + try: + res = sparse.csgraph.shortest_path(adjacency, method, directed, return_predecessors, + unweighted, False, sources) + except sparse.csgraph.NegativeCycleError: + raise ValueError("The shortest path computation could not be completed because a negative cycle is present.") + else: + try: + with Pool(n_jobs) as pool: + res = np.array(pool.map(local_function, sources)) + except sparse.csgraph.NegativeCycleError: + pool.terminate() + raise ValueError("The shortest path computation could not be completed because a negative cycle is present.") + if return_predecessors: + res[1][res[1] < 0] = -1 + if n == 1: + return res[0].ravel(), res[1].astype(int).ravel() + else: + return res[0], res[1].astype(int) + else: + if n == 1: + return res.ravel() + else: + return res + + +def get_shortest_path(adjacency: sparse.csr_matrix, sources: Union[int, Iterable], targets: Union[int, Iterable], + method: str = 'D', unweighted: bool = False, n_jobs: Optional[int] = None): + """Compute the shortest paths in the graph. + + Parameters + ---------- + adjacency : + The adjacency matrix of the graph + sources : int or iterable + Sources nodes. + targets : int or iterable + Target nodes. + method : + The method to be used. + + * ``'D'`` (Dijkstra), + * ``'BF'`` (Bellman-Ford), + * ``'J'`` (Johnson). + unweighted : + If ``True``, the weights of the edges are ignored + n_jobs : + If an integer value is given, denotes the number of workers to use (-1 means the maximum number will be used). + If ``None``, no parallel computations are made. + + Returns + ------- + paths : list + If single source and single target, return a list containing the nodes on the path from source to target. + If multiple sources or multiple targets, return a list of paths as lists. + An empty list means that the path does not exist. + + Examples + -------- + >>> from sknetwork.data import linear_digraph + >>> adjacency = linear_digraph(3) + >>> get_shortest_path(adjacency, 0, 2) + [0, 1, 2] + >>> get_shortest_path(adjacency, 2, 0) + [] + >>> get_shortest_path(adjacency, 0, [1, 2]) + [[0, 1], [0, 1, 2]] + >>> get_shortest_path(adjacency, [0, 1], 2) + [[0, 1, 2], [1, 2]] + """ + if np.issubdtype(type(sources), np.integer): + sources = [sources] + if np.issubdtype(type(targets), np.integer): + targets = [targets] + + if len(sources) == 1: + source2target = True + source = sources[0] + elif len(targets) == 1: + source2target = False + source = targets[0] + targets = sources + else: + raise ValueError( + 'This request is ambiguous. Either use one source and multiple targets or multiple sources and one target.') + + if source2target: + dists, preds = get_distances(adjacency, source, method, True, unweighted, n_jobs) + else: + dists, preds = get_distances(adjacency.T, source, method, True, unweighted, n_jobs) + + paths = [] + for target in targets: + if dists[target] == np.inf: + path = [] + else: + path = [target] + node = target + while node != source: + node = preds[node] + path.append(node) + if source2target: + path.reverse() + paths.append(path) + if len(paths) == 1: + paths = paths[0] + return paths diff --git a/setup.cfg b/setup.cfg index 3b3e965..aea48e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -57,7 +57,6 @@ install_requires = bw2calc >=2.0.dev13 matrix_utils numpy - scikit-network ==0.30.0 scipy bw_processing