Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Graph element #2145

Merged
merged 5 commits into from Nov 23, 2017
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 6 additions & 4 deletions examples/reference/elements/bokeh/Graph.ipynb
Expand Up @@ -90,7 +90,7 @@
"source": [
"#### Additional features\n",
"\n",
"Next we will extend this example by supplying explicit edges:"
"Next we will extend this example by supplying explicit edges, node information and edge weights. By constructing the ``Nodes`` explicitly we can declare an additional value dimensions, which are revealed when hovering and/or can be mapped to the color by specifying the ``color_index``. We can also associate additional information with each edge by supplying a value dimension to the ``Graph`` itself, which we can map to a color using the ``edge_color_index``."
]
},
{
Expand All @@ -100,8 +100,10 @@
"outputs": [],
"source": [
"# Node info\n",
"np.random.seed(7)\n",
"x, y = simple_graph.nodes.array([0, 1]).T\n",
"node_labels = ['Output']+['Input']*(N-1)\n",
"edge_weights = np.random.rand(8)\n",
"\n",
"# Compute edge paths\n",
"def bezier(start, end, control, steps=np.linspace(0, 1, 100)):\n",
Expand All @@ -114,10 +116,10 @@
"\n",
"# Declare Graph\n",
"nodes = hv.Nodes((x, y, node_indices, node_labels), vdims='Type')\n",
"graph = hv.Graph(((source, target), nodes, paths))\n",
"graph = hv.Graph(((source, target, edge_weights), nodes, paths), vdims='Weight')\n",
"\n",
"graph.redim.range(**padding).opts(plot=dict(color_index='Type'),\n",
" style=dict(cmap=['blue', 'yellow']))"
"graph.redim.range(**padding).opts(plot=dict(color_index='Type', edge_color_index='Weight'),\n",
" style=dict(cmap=['blue', 'red'], edge_cmap='viridis'))"
]
}
],
Expand Down
13 changes: 7 additions & 6 deletions examples/reference/elements/matplotlib/Graph.ipynb
Expand Up @@ -90,7 +90,8 @@
"source": [
"#### Additional features\n",
"\n",
"Next we will extend this example by supplying explicit edges:"
"\n",
"Next we will extend this example by supplying explicit edges, node information and edge weights. By constructing the ``Nodes`` explicitly we can declare an additional value dimensions, which are revealed when hovering and/or can be mapped to the color by specifying the ``color_index``. We can also associate additional information with each edge by supplying a value dimension to the ``Graph`` itself, which we can map to a color using the ``edge_color_index``."
]
},
{
Expand All @@ -99,11 +100,11 @@
"metadata": {},
"outputs": [],
"source": [
"from matplotlib.colors import ListedColormap\n",
"\n",
"# Node info\n",
"np.random.seed(7)\n",
"x, y = simple_graph.nodes.array([0, 1]).T\n",
"node_labels = ['Output']+['Input']*(N-1)\n",
"edge_weights = np.random.rand(8)\n",
"\n",
"# Compute edge paths\n",
"def bezier(start, end, control, steps=np.linspace(0, 1, 100)):\n",
Expand All @@ -116,10 +117,10 @@
"\n",
"# Declare Graph\n",
"nodes = hv.Nodes((x, y, node_indices, node_labels), vdims='Type')\n",
"graph = hv.Graph(((source, target), nodes, paths))\n",
"graph = hv.Graph(((source, target, edge_weights), nodes, paths), vdims='Weight')\n",
"\n",
"graph.redim.range(**padding).opts(plot=dict(color_index='Type'),\n",
" style=dict(cmap=ListedColormap(['blue', 'yellow'])))"
"graph.redim.range(**padding).opts(plot=dict(color_index='Type', edge_color_index='Weight'),\n",
" style=dict(cmap=['blue', 'red'], edge_cmap='viridis'))"
]
}
],
Expand Down
13 changes: 7 additions & 6 deletions examples/user_guide/Network_Graphs.ipynb
Expand Up @@ -43,8 +43,8 @@
"source": [
"# Declare abstract edges\n",
"N = 8\n",
"node_indices = np.arange(N)\n",
"source = np.zeros(N)\n",
"node_indices = np.arange(N, dtype=np.int32)\n",
"source = np.zeros(N, dtype=np.int32)\n",
"target = node_indices\n",
"\n",
"padding = dict(x=(-1.2, 1.2), y=(-1.2, 1.2))\n",
Expand Down Expand Up @@ -148,7 +148,7 @@
"source": [
"#### Additional information\n",
"\n",
"We can also associate additional information with the nodes and edges of a graph. By constructing the ``Nodes`` explicitly we can declare an additional value dimensions, which are revealed when hovering and/or can be mapped to the color by specifying the ``color_index``. We can also associate additional information with each edge by supplying a value dimension to the ``Graph`` itself."
"We can also associate additional information with the nodes and edges of a graph. By constructing the ``Nodes`` explicitly we can declare an additional value dimensions, which are revealed when hovering and/or can be mapped to the color by specifying the ``color_index``. We can also associate additional information with each edge by supplying a value dimension to the ``Graph`` itself, which we can map to a color using the ``edge_color_index``."
]
},
{
Expand All @@ -157,12 +157,13 @@
"metadata": {},
"outputs": [],
"source": [
"%%opts Graph [color_index='Type'] (cmap='Set1')\n",
"%%opts Graph [color_index='Type' edge_color_index='Weight'] (cmap='Set1' edge_cmap='viridis')\n",
"node_labels = ['Output']+['Input']*(N-1)\n",
"edge_labels = list('ABCDEFGH')\n",
"np.random.seed(7)\n",
"edge_labels = np.random.rand(8)\n",
"\n",
"nodes = hv.Nodes((x, y, node_indices, node_labels), vdims='Type')\n",
"graph = hv.Graph(((source, target, edge_labels), nodes, paths), vdims='Label').redim.range(**padding)\n",
"graph = hv.Graph(((source, target, edge_labels), nodes, paths), vdims='Weight').redim.range(**padding)\n",
"graph + graph.opts(plot=dict(inspection_policy='edges'))"
]
},
Expand Down
9 changes: 9 additions & 0 deletions holoviews/core/util.py
Expand Up @@ -1569,3 +1569,12 @@ def dt_to_int(value, time_unit='us'):
except:
# Handle python2
return (time.mktime(value.timetuple()) + value.microsecond / 1e6) * tscale


def search_indices(values, source):
"""
Given a set of values returns the indices of each of those values
in the source array.
"""
orig_indices = source.argsort()
return orig_indices[np.searchsorted(source[orig_indices], values)]
133 changes: 115 additions & 18 deletions holoviews/element/graphs.py
Expand Up @@ -34,13 +34,65 @@ def __call__(self, specs=None, **dimensions):


def circular_layout(nodes):
"""
Lay out nodes on a circle and add node index.
"""
N = len(nodes)
circ = np.pi/N*np.arange(N)*2
x = np.cos(circ)
y = np.sin(circ)
return (x, y, nodes)


def connect_edges_pd(graph):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move some of these functions to element.util?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sounds good.

"""
Given a Graph element containing abstract edges compute edge
segments directly connecting the source and target nodes. This
operation depends on pandas and is a lot faster than the pure
NumPy equivalent.
"""
edges = graph.dframe()
edges.index.name = 'graph_edge_index'
edges = edges.reset_index()
nodes = graph.nodes.dframe()
src, tgt = graph.kdims
x, y, idx = graph.nodes.kdims[:3]

df = pd.merge(edges, nodes, left_on=[src.name], right_on=[idx.name])
df = df.rename(columns={x.name: 'src_x', y.name: 'src_y'})

df = pd.merge(df, nodes, left_on=[tgt.name], right_on=[idx.name])
df = df.rename(columns={x.name: 'dst_x', y.name: 'dst_y'})
df = df.sort_values('graph_edge_index').drop(['graph_edge_index'], axis=1)

edge_segments = []
N = len(nodes)
for i, edge in df.iterrows():
start = edge['src_x'], edge['src_y']
end = edge['dst_x'], edge['dst_y']
edge_segments.append(np.array([start, end]))
return edge_segments


def connect_edges(graph):
"""
Given a Graph element containing abstract edges compute edge
segments directly connecting the source and target nodes. This
operation just uses internal HoloViews operations and will be a
lot slower than the pandas equivalent.
"""
paths = []
for start, end in graph.array(graph.kdims):
start_ds = graph.nodes[:, :, start]
end_ds = graph.nodes[:, :, end]
if not len(start_ds) or not len(end_ds):
raise ValueError('Could not find node positions for all edges')
start = start_ds.array(start_ds.kdims[:2])
end = end_ds.array(end_ds.kdims[:2])
paths.append(np.array([start[0], end[0]]))
return paths


class layout_nodes(Operation):
"""
Accepts a Graph and lays out the corresponding nodes with the
Expand Down Expand Up @@ -75,10 +127,15 @@ def _process(self, element, key=None):
nodes = nodes[['x', 'y', 'index']]
else:
nodes = circular_layout(nodes)
nodes = Nodes(nodes)
if element._nodes:
for d in element.nodes.vdims:
vals = element.nodes.dimension_values(d)
nodes = nodes.add_dimension(d, len(nodes.vdims), vals, vdim=True)
if self.p.only_nodes:
return Nodes(nodes)
return nodes
return element.clone((element.data, nodes))



class Graph(Dataset, Element2D):
Expand Down Expand Up @@ -123,15 +180,61 @@ def __init__(self, data, kdims=None, vdims=None, **params):
self._nodes = nodes
self._edgepaths = edgepaths
super(Graph, self).__init__(edges, kdims=kdims, vdims=vdims, **params)
if self._nodes is None and node_info:
nodes = self.nodes.clone(datatype=['pandas', 'dictionary'])
for d in node_info.dimensions():
if node_info is not None:
self._add_node_info(node_info)
self._validate()
self.redim = redim_graph(self, mode='dataset')


def _add_node_info(self, node_info):
nodes = self.nodes.clone(datatype=['pandas', 'dictionary'])
if isinstance(node_info, Nodes):
nodes = nodes.redim(**dict(zip(nodes.dimensions('key', label=True),
node_info.kdims)))

if not node_info.kdims and len(node_info) != len(nodes):
raise ValueError("The supplied node data does not match "
"the number of nodes defined by the edges. "
"Ensure that the number of nodes match"
"or supply an index as the sole key "
"dimension to allow the Graph to merge "
"the data.")

if pd is None:
if node_info.kdims and len(node_info) != len(nodes):
raise ValueError("Graph cannot merge node data on index "
"dimension without pandas. Either ensure "
"the node data matches the order of nodes "
"as they appear in the edge data or install "
"pandas.")
dimensions = nodes.dimensions()
for d in node_info.vdims:
if d in dimensions:
continue
nodes = nodes.add_dimension(d, len(nodes.vdims),
node_info.dimension_values(d),
vdim=True)
self._nodes = nodes
self._validate()
self.redim = redim_graph(self, mode='dataset')
else:
left_on = nodes.kdims[-1].name
node_info_df = node_info.dframe()
node_df = nodes.dframe()
if node_info.kdims:
idx = node_info.kdims[-1]
else:
idx = Dimension('index')
node_info_df = node_info_df.reset_index()
if 'index' in node_info_df.columns and not idx.name == 'index':
node_df = node_df.rename(columns={'index': '__index'})
left_on = '__index'
cols = [c for c in node_info_df.columns if c not in
node_df.columns or c == idx.name]
node_info_df = node_info_df[cols]
node_df = pd.merge(node_df, node_info_df, left_on=left_on,
right_on=idx.name, how='left')
nodes = nodes.clone(node_df, kdims=nodes.kdims[:2]+[idx],
vdims=node_info.vdims)

self._nodes = nodes


def _validate(self):
Expand Down Expand Up @@ -300,15 +403,10 @@ def edgepaths(self):
"""
if self._edgepaths:
return self._edgepaths
paths = []
for start, end in self.array(self.kdims):
start_ds = self.nodes[:, :, start]
end_ds = self.nodes[:, :, end]
if not len(start_ds) or not len(end_ds):
raise ValueError('Could not find node positions for all edges')
sx, sy = start_ds.array(start_ds.kdims[:2]).T
ex, ey = end_ds.array(end_ds.kdims[:2]).T
paths.append([(sx[0], sy[0]), (ex[0], ey[0])])
if pd is None:
paths = connect_edges(self)
else:
paths = connect_edges_pd(self)
return EdgePaths(paths, kdims=self.nodes.kdims[:2])


Expand Down Expand Up @@ -354,4 +452,3 @@ class EdgePaths(Path):
"""

group = param.String(default='EdgePaths', constant=True)