paulbrodersen / netgraph

Publication-quality network visualisations in python
GNU General Public License v3.0
660 stars 39 forks source link

changing edge style #52

Closed lcastri closed 1 year ago

lcastri commented 1 year ago

Hi,

using an old version of the software I generated this graph: atc_hg

now I updated the software and I am trying to generate the same graph, but now the edge style is different: dag

Is there a way to set the edge style in order to get the graph as in the first image?

Thanks

paulbrodersen commented 1 year ago

Agreed, that looks terrible.

Is there a way to set the edge style in order to get the graph as in the first image?

There isn't but your example convinced me that there probably should be. The parallel edges really don't work very well if the edges are labelled. I don't have much time today or Friday but I will look into it next week.

Also, that kink in the middle of both edges is really unexpected. Could you make a MWE that reproduces the figure? I just want to check that you are not setting some parameter to a weird value (k?).

paulbrodersen commented 1 year ago

My attempt at reproducing your plot yields no kink:

#!/usr/bin/env python
"""
Issue 52
"""
import matplotlib.pyplot as plt

from netgraph import Graph

edges = [
    (0, 1),
    (1, 0),
    (0, 2),
    (2, 0)
]

Graph(edges, node_labels=True, edge_labels=True, edge_label_position=0.33, edge_layout='curved', arrows=True)
plt.show()

Figure_1

lcastri commented 1 year ago

I am using this fnc to generate the graph. Attached you can find the result.pkl to load and input to the fnc

def dag(result,
        alpha = None,
        min_width = 1,
        max_width = 5,
        node_color = 'orange',
        edge_color = 'grey',
        font_size = 12,
        show_edge_labels = True,
        save_name = None):
    """
    build a dag
    Args:
        result (dict): result from pcmci
        alpha (float): significance level. Defaults to None
        min_width (int, optional): minimum linewidth. Defaults to 1.
        max_width (int, optional): maximum linewidth. Defaults to 5.
        node_color (str, optional): node color. Defaults to 'orange'.
        edge_color (str, optional): edge color. Defaults to 'grey'.
        font_size (int, optional): font size. Defaults to 12.
        show_edge_labels (bool, optional): bit to show the time-lag label of the dependency on the edge. Defaults to True.
        save_name (str, optional): Filename path. If None, plot is shown and not saved. Defaults to None.
    """

    if alpha is not None: result['graph'] = __apply_alpha(result, alpha)
    res = __PCMCIres_converter(result)

    G = nx.DiGraph()

    # add nodes
    G.add_nodes_from(res.keys())
    border = {t: __scale(s[SCORE], min_width, max_width) for t in res.keys() for s in res[t] if t == s[SOURCE]}

    if show_edge_labels:
        # node_label = {t: round(s[SCORE], 3) for t in res.keys() for s in res[t] if t == s[SOURCE]}
        node_label = {t: s[LAG] for t in res.keys() for s in res[t] if t == s[SOURCE]}
    else:
        node_label = None

    # edges definition
    edges = [(s[SOURCE], t) for t in res.keys() for s in res[t] if t != s[SOURCE]]
    G.add_edges_from(edges)
    edge_width = {(s[SOURCE], t): __scale(s[SCORE], min_width, max_width) for t in res.keys() for s in res[t] if t != s[SOURCE]}
    if show_edge_labels:
        # edge_label = {(s[SOURCE], t): round(s[SCORE], 3) for t in res.keys() for s in res[t] if t != s[SOURCE]}
        edge_label = {(s[SOURCE], t): s[LAG] for t in res.keys() for s in res[t] if t != s[SOURCE]}
    else:
        edge_label = None

    fig, ax = plt.subplots(figsize=(8,6))

    a = Graph(G, 
              node_layout = 'circular',
              node_size = 10,
              node_color = node_color,
              node_labels = node_label,
              node_edge_width = border,
              node_label_fontdict = dict(size=font_size),
              node_edge_color = edge_color,
              node_label_offset = 0.15,
              node_alpha = 1,

              arrows = True,
              edge_layout = 'curved',
              edge_label = show_edge_labels,
              edge_labels = edge_label,
              edge_label_fontdict = dict(size=font_size),
              edge_color = edge_color, 
              edge_width = edge_width,
              edge_alpha = 1,
              edge_zorder = 1,
              edge_label_position = 0.35)

    nx.draw_networkx_labels(G, 
                            pos = a.node_positions,
                            labels = {n: n for n in G},
                            font_size = font_size)
    if save_name is not None:
        plt.savefig(save_name, dpi = 300)
    else:
        plt.show()

if __name__ == '__main__':
    file_path = "result.pkl"
    with open(file_path, 'rb') as f:
        result = pickle.load(f)

    dag(result, alpha = result['alpha'], save_name = 'dag.png', font_size=18)

result.zip

lcastri commented 1 year ago

Sorry I didn't notice there were other fnc to attach

def __scale(score, min_width, max_width, min_score = 0, max_score = 1):
    """
    Scales the score of the cause-effect relationship strength to a linewitdth
    Args:
        score (float): score to scale
        min_width (float): minimum linewidth
        max_width (float): maximum linewidth
        min_score (int, optional): minimum score range. Defaults to 0.
        max_score (int, optional): maximum score range. Defaults to 1.
    Returns:
        float: scaled score
    """
    return ((score-min_score)/(max_score-min_score))*(max_width-min_width)+min_width

def __convert_to_string_graph(graph_bool):
    """Converts the 0,1-based graph returned by PCMCI to a string array
    with links '-->'.
    Parameters
    ----------
    graph_bool : array
        0,1-based graph array output by PCMCI.
    Returns
    -------
    graph : array
        graph as string array with links '-->'.
    """
    graph = np.zeros(graph_bool.shape, dtype='<U3')
    graph[:] = ""
    # Lagged links
    graph[:,:,1:][graph_bool[:,:,1:]==1] = "-->"
    # Unoriented contemporaneous links
    graph[:,:,0][np.logical_and(graph_bool[:,:,0]==1, 
                                graph_bool[:,:,0].T==1)] = "o-o"
    # Conflicting contemporaneous links
    graph[:,:,0][np.logical_and(graph_bool[:,:,0]==2, 
                                graph_bool[:,:,0].T==2)] = "x-x"
    # Directed contemporaneous links
    for (i,j) in zip(*np.where(
        np.logical_and(graph_bool[:,:,0]==1, graph_bool[:,:,0].T==0))):
        graph[i,j,0] = "-->"
        graph[j,i,0] = "<--"
    return graph

def __apply_alpha(result, alpha):
    """
    Applies alpha threshold to the pcmci result
    Args:
        result (dict): pcmci result
        alpha (float): significance level
    Returns:
        ndarray: graph filtered by alpha 
    """
    mask = np.ones(result['p_matrix'].shape, dtype='bool')
    # Set all p-values of absent links to 1.
    result['p_matrix'][mask==False] == 1.
    # Threshold p_matrix to get graph
    graph_bool = result['p_matrix'] <= alpha
    # Convert to string graph representation
    graph = __convert_to_string_graph(graph_bool)

    return graph

def __PCMCIres_converter(result):
    """
    Re-elaborates the PCMCI result in a new dictionary
    Args:
        result (dict): pcmci result
    Returns:
        dict: pcmci result re-elaborated
    """
    res_dict = {f:list() for f in result['var_names']}
    N, lags = result['graph'][0].shape
    for s in range(len(result['graph'])):
        for t in range(N):
            for lag in range(lags):
                if result['graph'][s][t,lag] == '-->':
                    res_dict[result['var_names'][t]].append({SOURCE : result['var_names'][s],
                                                             SCORE : result['val_matrix'][s][t,lag],
                                                             PVAL : result['p_matrix'][s][t,lag],
                                                             LAG : lag})
    return res_dict

with all of them it should run

paulbrodersen commented 1 year ago

What version of netgraph are you using?

paulbrodersen commented 1 year ago

Some progress. For whatever reason, using very large nodes induces the kinks. What the heck?

Figure_1

lcastri commented 1 year ago

I am using the 4.9.3 version

paulbrodersen commented 1 year ago

Alright, I have added a flag that allows toggling off the bundling of bi-directional edges. Still working on the issue with the kinks but there may be no easy fix for that as my first experiments suggest that those are simply the correct result given the physics simulation in the Fruchterman-Reingold simulation for optimizing edge control point positions. In the meantime, I suggest you use smaller nodes.

Figure_1

import matplotlib.pyplot as plt

from netgraph import Graph

edges = [
    (0, 1),
    (1, 0),
    (0, 2),
    (2, 0)
]

fig, axes = plt.subplots(1, 2)
Graph(edges,
      node_size=5,
      node_labels=True,
      edge_label_position=0.33,
      node_layout='circular',
      edge_layout='curved',
      edge_layout_kwargs=dict(bundle_parallel_edges=False, k=0.05),
      arrows=True,
      ax=axes[0])
Graph(edges,
      node_size=10,
      node_labels=True,
      edge_label_position=0.33,
      node_layout='circular',
      edge_layout='curved',
      edge_layout_kwargs=dict(bundle_parallel_edges=False, k=0.05),
      arrows=True,
      ax=axes[1])
plt.show()
lcastri commented 1 year ago

Thanks a lot! So the flag is the following:

edge_layout_kwargs=dict(bundle_parallel_edges=False, k=0.05),
paulbrodersen commented 1 year ago

Correct, specifically the bundle_parallel_edges=False bit. I set k = 0.05 as it straightens the edges a little bit which helps with the kinks.

paulbrodersen commented 1 year ago

Closing the issue for now as the kinks (1) only seem to appear under fairly special circumstances (giant nodes), and (2) seem to be valid solutions of the edge layout algorithm.

lcastri commented 1 year ago

Hi Paul,

thanks for the fix. I think this fix creates (again) another issue that was already solved here #48

Nodes = ['$X_0$', '$X_1$', '$X_2$', '$X_3$', '$X_4$', '$X_5$']
Edges = [('$X_1$', '$X_0$'), ('$X_1$', '$X_2$'), ('$X_2$', '$X_0$'), ('$X_2$', '$X_1$'), ('$X_5$', '$X_4$')])

Graph(G, 
                node_layout = 'dot',
                node_size = 8,
                node_alpha = 1,

                arrows = True,
                edge_layout = 'curved',
                edge_layout_kwargs = dict(bundle_parallel_edges = False, k = 0.05))

Output

Some given node positions are not within the data range specified by `origin` and `scale`!
    Origin : 0.0, 0.0
    Scale  : 0.6763301515504611, 1.0
The following nodes do not fall within this range:
    60f9710e-a859-4ee3-8223-f842172fad6b : [0.7994626  0.47216302]
    18737562-f6e3-44e6-a77c-6e4aa6553518 : [0.7994626  0.35412226]
    f4d83eff-1845-4f0b-bb19-411250848aac : [0.7994626  0.23608151]
    2817e2c1-af1d-4f6c-8285-bb2792f0b16a : [0.7994626  0.11804075]
    $X_5$ : [0.79946319 0.59020377]
    $X_4$ : [ 0.79946319 -0.        ]
    $X_3$ : [0.71410453 0.83050847]
This error can occur if the graph contains multiple components but some or all node positions are initialised explicitly (i.e. node_positions != None).

If instead, I set bundle_parallel_edges = True the graph is plotted without errors.

Luca

paulbrodersen commented 1 year ago

Thanks for the heads up! I will look into it later this week.

paulbrodersen commented 1 year ago

I re-applied the previous fix. No idea why I thought it no longer was necessary. Setting parallel_edges = False now also works in combination with a dot node layout and multiple components.