diff --git a/networkz/algorithms/max_weight_fractional_matching.py b/networkz/algorithms/max_weight_fractional_matching.py new file mode 100644 index 0000000..652fefd --- /dev/null +++ b/networkz/algorithms/max_weight_fractional_matching.py @@ -0,0 +1,123 @@ +import numpy as np +from scipy.optimize import linprog + +import networkz as nx +from networkz import incidence_matrix + + +def maximum_weight_fractional_matching(G: nx.Graph, weight="weight", **linprog_options): + """Returns the maximum-weight fractional matching of the wighted graph `G`. + + A fractional graph is a graph in which every edge has a fraction [0,1] + such that the sum of fractions of edges adjacent to each vertex is at most 1. + A matching is a set of edges that do not share any nodes. + Define fw(e) for each edge e to be the multiplication of its weight and fraction. + A maximum-weight fractional matching is one with the maximum fw(e) sum of all e in E(G). + + A fractional matching of maximum weight in a graph can be found by linear programming. + + *If the edges are not weighted - the weight of each edge is 1. + + Parameters + ---------- + G : NetworkX graph + Undirected weighted graph + weight : str + the name of the edge attribute that represents the weight of an edge. + linprog_options : dict + scipy.optimize.linprog options, None as default. + + Returns + ------- + F : dictionary + + The fractions are returned as a dictionary, `frac`, such that + ``frac[e] == f`` for edge `e` with fraction `f` (rounded to 3 decimals). + + Examples + -------- + In the weighted graph, G = (V,E). + >>> G = nx.Graph() + >>> G.add_nodes_from(["a1", "a2"]) + >>> G.add_edge("a1", "a2", weight=3) + >>> F = maximum_weight_fractional_matching(G) + >>> print(F=={('a1', 'a2'): 1.0}) + True + >>> F[('a1','a2')] + 1.0 + + explanation: weight = 3 + G = a1-----------a2 + + frac = 1.0 + maximum_weight_fractional_matching(G) = a1-----------a2 + + The returned value is {('a1', 'a2'): 1.0}. + There is only one edge, so it gets the maximaum value. + + another example: + >>> G = nx.Graph() + >>> G.add_nodes_from(["a1", "a2", "a3"]) + >>> G.add_weighted_edges_from([("a1", "a2", 1), ("a1", "a3", 2), ("a2", "a3", 3)]) + >>> F = maximum_weight_fractional_matching(G) + >>> print(F=={('a1', 'a2'): 0.5, ('a1', 'a3'): 0.5, ('a2', 'a3'): 0.5}) + True + >>> F[('a2','a3')] + 0.5 + + explanation: weight = 1 + G = a1------------a2 + \\ \\ + weight = 2 \\ \\ weight = 3 + \\ \\ + \\ \\ + a3 + + frac = 0.5 + maximum_weight_fractional_matching(G) = a1------------a2 + \\ \\ + frac = 0.5 \\ \\ frac = 0.5 + \\ \\ + \\ \\ + a3 + + The returned value is {('a1', 'a2'): 0.5, ('a1', 'a3'): 0.5, ('a2', 'a3'): 0.5}. + We want to find Max(x,y,z) S.T + a1: x +2y<=1 + a2: x+3z<=1 + a3: 2y+3z<=1 + and + x,y,z<=1 + we can solve it using the linprog function: + linprog(c, A_ub, b_ub, bounds, method='highs') + linprog solve the same problem, but it finds the Min(x,y,z) so if we want Max(x,y,z) + we can change our inqualities to be: + Min(x,y,z) + S.T + a1: x +2y>=-1 + a2: x+3z>=-1 + a3: 2y+3z>=-1 + set bounds = (-1, 0) + and then take the result as ABS, like that - |Min(x,y,z)| + than we will get the solution for our original problem = {('a1', 'a2'): 0.5, ('a1', 'a3'): 0.5, ('a2', 'a3'): 0.5} + + + See Also + -------- + linprog + + References + ---------- + https://en.wikipedia.org/wiki/Fractional_matching + """ + + if G.number_of_nodes() == 0 or G.number_of_edges() == 0: + return dict() + c = [G.edges[edge].get(weight, 1) for edge in G.edges] + b = [1] * len(G.nodes) + bounds = (-1, 0) + A = -incidence_matrix(G) + res = linprog( + c, A_ub=A, b_ub=b, bounds=bounds, method="highs", options=linprog_options + ) + return dict(zip(G.edges, np.abs(np.round(res.x, 3)))) diff --git a/networkz/algorithms/tests/test_max_weight_fractional_matching.py b/networkz/algorithms/tests/test_max_weight_fractional_matching.py new file mode 100644 index 0000000..c660c69 --- /dev/null +++ b/networkz/algorithms/tests/test_max_weight_fractional_matching.py @@ -0,0 +1,173 @@ +from random import randint + +import pytest + +np = pytest.importorskip("numpy") + +import networkz as nx +import networkz.algorithms.max_weight_fractional_matching as mw + + +def get_max_weight_frac(res, G=nx.Graph()): + return sum(frac * G.edges[edge]["weight"] for edge, frac in res.items()) + + +class TestMaximumWeightFractionalMatching: + def test_empty_graph(self): + G = nx.Graph() + res = mw.maximum_weight_fractional_matching(G) + assert {} == res + + def test_graph_without_edges(self): + G = nx.Graph() + G.add_nodes_from([i for i in range(0, 10)]) + res = mw.maximum_weight_fractional_matching(G) + assert {} == res + + def test_simple_graph_without_weights(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2"]) + G.add_edge("a1", "a2") + res = mw.maximum_weight_fractional_matching(G) + assert {("a1", "a2"): 1.0} == res + + def test_simple_graph_with_weight(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2"]) + G.add_edge("a1", "a2", weight=3) + res = mw.maximum_weight_fractional_matching(G) + assert {("a1", "a2"): 1.0} == res + + def test_simple_graph_with_negative_weight(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2"]) + G.add_edge("a1", "a2", weight=-1) + res = mw.maximum_weight_fractional_matching(G) + assert {("a1", "a2"): 0} == res + + def test_3_nodes_graph_without_weights(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2", "a3"]) + G.add_edges_from([("a1", "a2"), ("a1", "a3"), ("a2", "a3")]) + res = mw.maximum_weight_fractional_matching(G) + max_weight = np.round(sum(frac for frac in res.values()), 3) + exp_val = {("a1", "a2"): 0.5, ("a1", "a3"): 0.5, ("a2", "a3"): 0.5} + weight = np.round(sum(frac for frac in exp_val.values()), 3) + assert weight == max_weight + + def test_simple_graph_with_equal_weights(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2", "a3"]) + G.add_weighted_edges_from([("a1", "a2", 5), ("a1", "a3", 5), ("a2", "a3", 5)]) + res = mw.maximum_weight_fractional_matching(G) + max_weight = get_max_weight_frac(res, G) + exp_val = {("a1", "a2"): 0.5, ("a1", "a3"): 0.5, ("a2", "a3"): 0.5} + weight = get_max_weight_frac(exp_val, G) + assert weight == max_weight + + def test_3_nodes_graph_1_3_weights(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2", "a3"]) + G.add_weighted_edges_from([("a1", "a2", 1), ("a1", "a3", 2), ("a2", "a3", 3)]) + res = mw.maximum_weight_fractional_matching(G) + max_weight = get_max_weight_frac(res, G) + exp_val = {("a1", "a2"): 0.5, ("a1", "a3"): 0.5, ("a2", "a3"): 0.5} + weight = get_max_weight_frac(exp_val, G) + assert weight == max_weight + + def test_3_nodes_graph_with_weights(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2", "a3"]) + G.add_weighted_edges_from([("a1", "a2", 1), ("a1", "a3", 2), ("a2", "a3", 4)]) + res = mw.maximum_weight_fractional_matching(G) + max_weight = get_max_weight_frac(res, G) + exp_val = {("a1", "a2"): 0.0, ("a1", "a3"): 0.0, ("a2", "a3"): 1.0} + weight = get_max_weight_frac(exp_val, G) + assert weight == max_weight + + def test_3_nodes_graph_with_negative_weight(self): + G = nx.Graph() + G.add_nodes_from(["a1", "a2", "a3"]) + G.add_weighted_edges_from([("a1", "a2", -1), ("a1", "a3", 5), ("a2", "a3", 10)]) + res = mw.maximum_weight_fractional_matching(G) + max_weight = get_max_weight_frac(res, G) + exp_val = {("a1", "a2"): 0.0, ("a1", "a3"): 0.0, ("a2", "a3"): 1.0} + weight = get_max_weight_frac(exp_val, G) + assert weight == max_weight + + def test_7_nodes_graph_without_weights(self): + G = nx.Graph() + G.add_nodes_from([i for i in range(0, 7)]) + G.add_edges_from( + [(0, 1), (0, 2), (0, 6), (0, 5), (1, 6), (2, 3), (2, 4), (2, 6), (3, 4)] + ) + res = mw.maximum_weight_fractional_matching(G) + max_weight = np.round(sum(frac for frac in res.values()), 3) + exp_val = { + (0, 1): 0.0, + (0, 2): 0.0, + (0, 6): 0.0, + (0, 5): 1.0, + (1, 6): 1.0, + (2, 3): 0.5, + (2, 4): 0.5, + (2, 6): 0.0, + (3, 4): 0.5, + } + weight = np.round(sum(frac for frac in exp_val.values()), 3) + assert weight == max_weight + + def test_7_nodes_graph_with_weights(self): + G = nx.Graph() + G.add_nodes_from([i for i in range(0, 7)]) + G.add_weighted_edges_from( + [ + (0, 1, 12), + (0, 2, 7), + (0, 6, 21), + (0, 5, 6), + (1, 6, 1), + (2, 3, 23), + (2, 4, 5), + (2, 6, 8), + (3, 4, 19), + ] + ) + res = mw.maximum_weight_fractional_matching(G) + max_weight = get_max_weight_frac(res, G) + exp_val = { + (0, 1): 0.0, + (0, 2): 0.0, + (0, 6): 1.0, + (0, 5): 0.0, + (1, 6): 0.0, + (2, 3): 0.5, + (2, 4): 0.5, + (2, 6): 0.0, + (3, 4): 0.5, + } + weight = get_max_weight_frac(exp_val, G) + assert weight == max_weight + + def test_completes_graph_without_weights(self): + for i in range(0, 5): + n = randint(2, 30) + G = nx.complete_graph(n) + res = mw.maximum_weight_fractional_matching(G) + max_weight = np.round(sum(frac for frac in res.values()), 3) + weight = np.round((1 / (n - 1)) * len(G.edges()), 3) + assert weight == max_weight + + def test_sum_of_weights_in_every_node_in_random_graph_is_less_than_1(self): + seed = 1 + for i in range(0, 5): + G = nx.random_regular_graph(4, 6, seed) + res = mw.maximum_weight_fractional_matching(G) + for node in G.nodes: + sum_adj = 0 + for edge in G.edges(node): + if edge in res: + sum_adj += res[edge] + else: + sum_adj += res[(edge[1], edge[0])] + assert sum_adj == 1