diff --git a/cdlib/test/test_viz_network.py b/cdlib/test/test_viz_network.py index 5f4d9793..e62a82a4 100644 --- a/cdlib/test/test_viz_network.py +++ b/cdlib/test/test_viz_network.py @@ -18,7 +18,12 @@ def test_nx_cluster(self): coms = algorithms.demon(g, 0.25) pos = nx.spring_layout(g) - viz.plot_network_clusters(g, coms, pos, plot_labels=True, plot_overlaps=True) + viz.plot_network_clusters(g, coms, pos, + plot_labels=True, + plot_overlaps=True, + show_edge_weights=True, + show_edge_widths=True, + show_node_sizes=True) plt.savefig("cluster.pdf") os.remove("cluster.pdf") @@ -33,7 +38,12 @@ def test_community_graph(self): os.remove("cg.pdf") coms = algorithms.demon(g, 0.25) - viz.plot_community_graph(g, coms, plot_overlaps=True, plot_labels=True) + viz.plot_community_graph(g, coms, + plot_labels=True, + plot_overlaps=True, + show_edge_weights=True, + show_edge_widths=True, + show_node_sizes=True) plt.savefig("cg.pdf") os.remove("cg.pdf") diff --git a/cdlib/viz/networks.py b/cdlib/viz/networks.py index ec8eb8bb..9bc03d11 100644 --- a/cdlib/viz/networks.py +++ b/cdlib/viz/networks.py @@ -6,6 +6,7 @@ from cdlib import NodeClustering from cdlib.utils import convert_graph_formats from community import community_louvain +from typing import Union __all__ = ["plot_network_clusters", "plot_community_graph"] @@ -43,12 +44,15 @@ def plot_network_clusters( partition: NodeClustering, position: dict = None, figsize: tuple = (8, 8), - node_size: int = 200, + node_size: Union[int, dict] = 200, # 200 default value plot_overlaps: bool = False, plot_labels: bool = False, cmap: object = None, top_k: int = None, min_size: int = None, + show_edge_widths: bool = False, + show_edge_weights: bool = False, + show_node_sizes: bool = False, ) -> object: """ Plot a graph with node color coding for communities. @@ -57,12 +61,15 @@ def plot_network_clusters( :param partition: NodeClustering object :param position: A dictionary with nodes as keys and positions as values. Example: networkx.fruchterman_reingold_layout(G). By default, uses nx.spring_layout(g) :param figsize: the figure size; it is a pair of float, default (8, 8) - :param node_size: int, default 200 + :param node_size: The size of nodes. It can be an integer or a dictionary mapping nodes to sizes. Default is 200. :param plot_overlaps: bool, default False. Flag to control if multiple algorithms memberships are plotted. :param plot_labels: bool, default False. Flag to control if node labels are plotted. :param cmap: str or Matplotlib colormap, Colormap(Matplotlib colormap) for mapping intensities of nodes. If set to None, original colormap is used. :param top_k: int, Show the top K influential communities. If set to zero or negative value indicates all. :param min_size: int, Exclude communities below the specified minimum size. + :param show_edge_widths: Flag to control if edge widths are shown. Default is False. + :param show_edge_weights: Flag to control if edge weights are shown. Default is False. + :param show_node_sizes: Flag to control if node sizes are shown. Default is False. Example: @@ -71,7 +78,7 @@ def plot_network_clusters( >>> g = nx.karate_club_graph() >>> coms = algorithms.louvain(g) >>> pos = nx.spring_layout(g) - >>> viz.plot_network_clusters(g, coms, pos) + >>> viz.plot_network_clusters(g, coms, pos, edge_weights=edge_weights) """ if not isinstance(cmap, (type(None), str, matplotlib.colors.Colormap)): raise TypeError( @@ -112,11 +119,40 @@ def plot_network_clusters( graph.edges(), ) ) - fig = nx.draw_networkx_nodes( - graph, position, node_size=node_size, node_color="w", nodelist=filtered_nodelist - ) - fig.set_edgecolor("k") - nx.draw_networkx_edges(graph, position, alpha=0.5, edgelist=filtered_edgelist) + if isinstance(node_size, int): + fig = nx.draw_networkx_nodes( + graph, position, node_size=node_size, node_color="w", nodelist=filtered_nodelist + ) + fig.set_edgecolor("k") + + filtered_edge_widths = [1] * len(filtered_edgelist) + + if show_edge_widths: + edge_widths = nx.get_edge_attributes(graph, "weight") + filtered_edge_widths = [weight for (edge, weight) in edge_widths.items() if edge[0] != edge[1]] + + min_weight = min(filtered_edge_widths) + max_weight = max(filtered_edge_widths) + + filtered_edge_widths = np.interp(filtered_edge_widths, (min_weight, max_weight), (1, 5)) + + nx.draw_networkx_edges(graph, position, alpha=0.5, edgelist=filtered_edgelist, width=filtered_edge_widths) + + if show_edge_weights: + edge_weights = nx.get_edge_attributes(graph, "weight") + filtered_edge_weights = [{edge: weight} for edge, weight in edge_weights.items() if edge[0] != edge[1]] + + for edge_weight in filtered_edge_weights: + nx.draw_networkx_edge_labels( + graph, + position, + edge_labels=edge_weight, + font_color="red", + label_pos=0.5, + font_size=8, + font_weight='bold', + ) + if plot_labels: nx.draw_networkx_labels( graph, @@ -124,12 +160,30 @@ def plot_network_clusters( font_color=".8", labels={node: str(node) for node in filtered_nodelist}, ) + + if isinstance(node_size, dict) and show_node_sizes: + # Extract values from the node_size dictionary + node_values = list(node_size.values()) + + # Interpolate node_size values to be between 200 and 500 + min_node_size = min(node_values) if node_values else 1 + max_node_size = max(node_values) if node_values else 1 + node_size = {key: np.interp(value, (min_node_size, max_node_size), (200, 1000)) for key, value in node_size.items()} + else: + node_size = 200 + for i in range(n_communities): if len(partition[i]) > 0: if plot_overlaps: - size = (n_communities - i) * node_size + if isinstance(node_size, dict): + size = (n_communities - i) * node_size[i] # Use interpolated size from dictionary + else: + size = (n_communities - i) * node_size # Use fixed size else: - size = node_size + if isinstance(node_size, dict): + size = node_size[i] # Use interpolated size from dictionary + else: + size = node_size # Use fixed size fig = nx.draw_networkx_nodes( graph, position, @@ -138,6 +192,7 @@ def plot_network_clusters( node_color=[cmap(_norm(i))], ) fig.set_edgecolor("k") + if plot_labels: for i in range(n_communities): if len(partition[i]) > 0: @@ -147,33 +202,109 @@ def plot_network_clusters( font_color=fontcolors[i], labels={node: str(node) for node in partition[i]}, ) - return fig +def calculate_cluster_edge_weights(graph, node_to_com): + """ + Calculate edge weights between different clusters. + + This function calculates the edge weights between nodes belonging to different clusters. + It iterates through the edges of the graph, identifies the communities of the source and target nodes, + and increments the edge weight for the corresponding cluster pair. + + :param graph: NetworkX/igraph graph + :param node_to_com: Dictionary mapping nodes to their community IDs + """ + cluster_edge_weights = {} + + for edge in graph.edges(): + source, target = edge + source_com = node_to_com.get(source, None) + target_com = node_to_com.get(target, None) + + if source_com is not None and target_com is not None and source_com != target_com: + # Nodes belong to different communities + cluster_pair = (source_com, target_com) + + # Check if edge data is not empty + edge_data = graph.get_edge_data(source_com, target_com) + + # Check if edge data is None, empty or not + if edge_data is None: + edge_weight = 0 + elif edge_data == {}: + edge_weight = 1 + else: # edge_data contains an element 'weight' : int + edge_weight = edge_data['weight'] + + if cluster_pair not in cluster_edge_weights: + cluster_edge_weights[cluster_pair] = edge_weight + else: + cluster_edge_weights[cluster_pair] += edge_weight + + cluster_edge_weights_array = [(source, target, weight) for (source, target), weight in cluster_edge_weights.items()] + graph.add_weighted_edges_from(cluster_edge_weights_array) + +def calculate_cluster_sizes(partition: NodeClustering) -> Union[int, dict]: + """ + Calculate the total weight of all nodes in each cluster. + + :param partition: The partition of the graph into clusters. + :type partition: NodeClustering + :return: If all clusters have the same size, return the size as an integer. + Otherwise, return a dictionary mapping cluster ID to the number of nodes in the cluster. + :rtype: Union[int, dict] + """ + cluster_sizes = {} + unique_values = set() + + for cid, com in enumerate(partition.communities): + total_weight = 0 + + #print("cluster: ", cid) + for node in com: + if 'weight' in partition.graph.nodes[node]: # If node data contains a 'weight' attribute + total_weight += partition.graph.nodes[node]['weight'] + else: # If node data is empty + total_weight += 1 # Default weight is 1 + + cluster_sizes[cid] = total_weight + + if len(unique_values) == 1: + return int(unique_values.pop()) # All elements have the same value, return that value as an integer + else: + return cluster_sizes # Elements have different values, return the dictionary + def plot_community_graph( graph: object, partition: NodeClustering, figsize: tuple = (8, 8), - node_size: int = 200, + node_size: Union[int, dict] = 200, plot_overlaps: bool = False, plot_labels: bool = False, cmap: object = None, top_k: int = None, min_size: int = None, + show_edge_weights: bool = True, + show_edge_widths: bool = True, + show_node_sizes: bool = True, ) -> object: """ - Plot a algorithms-graph with node color coding for communities. + Plot a algorithms-graph with node color coding for communities, where nodes represent clusters obtained from a community detection algorithm. :param graph: NetworkX/igraph graph :param partition: NodeClustering object :param figsize: the figure size; it is a pair of float, default (8, 8) - :param node_size: int, default 200 + :param node_size: The size of nodes. It can be an integer or a dictionary mapping nodes to sizes. Default is 200. :param plot_overlaps: bool, default False. Flag to control if multiple algorithms memberships are plotted. :param plot_labels: bool, default False. Flag to control if node labels are plotted. :param cmap: str or Matplotlib colormap, Colormap(Matplotlib colormap) for mapping intensities of nodes. If set to None, original colormap is used.. :param top_k: int, Show the top K influential communities. If set to zero or negative value indicates all. :param min_size: int, Exclude communities below the specified minimum size. + :param show_edge_widths: Flag to control if edge widths are shown. Default is True. + :param show_edge_weights: Flag to control if edge weights are shown. Default is True. + :param show_node_sizes: Flag to control if node sizes are shown. Default is True. Example: @@ -205,6 +336,14 @@ def plot_community_graph( c_graph = community_louvain.induced_graph(node_to_com, s) node_cms = [[node] for node in c_graph.nodes()] + # Calculate edge weights and edge widths for each cluster + if(show_edge_weights or show_edge_widths): + calculate_cluster_edge_weights(graph, node_to_com) + + if show_node_sizes: + # Calculate cluster sizes for adjusting node sizes + node_size = calculate_cluster_sizes(partition) + return plot_network_clusters( c_graph, NodeClustering(node_cms, None, ""), @@ -214,4 +353,7 @@ def plot_community_graph( plot_overlaps=plot_overlaps, plot_labels=plot_labels, cmap=cmap, + show_edge_weights=show_edge_weights, + show_edge_widths=show_edge_widths, + show_node_sizes=show_node_sizes, )