Visualisierung von Netzwerkgraphen

Bokeh unterstützt nativ die Erstellung von Netzwerkgraphen mit konfigurierbaren Interaktionen zwischen Kanten und Knoten.

Edge- und Node-Renderer

Das Hauptmerkmal von GraphRenderer ist, dass es separate GlyphRenderer für Diagrammknoten und Diagrammkanten gibt. Dies ermöglicht das Anpassen der Knoten durch Ändern einer Eigenschaft des node_renderer. Es ist möglich, den üblichen Kreisknoten-Glyph durch eine beliebige XYGlyph-Instanz zu ersetzen, z.B. durch einen rechteckigen oder ovalen Glyph. Ebenso können die Stileigenschaften der Kanten über die edge_renderer-Eigenschaft geändert werden. Aktuell können Kanten jedoch nur als MultiLine-Zeichen gerendert werden.

Für die Datenquellen, die zu diesen beiden Sub-Renderern gehören, gelten einige Anforderungen:

  • Die ColumnDataSource, die dem node_renderer zugeordnet ist, muss eine Spalte mit dem Namen "index" haben, die die eindeutigen Indizes der Knoten enthält.

  • Die ColumnDataSource, die dem edge_renderer zugeordnet ist, enthält zwei erforderliche Spalten: "start" und "end". Diese Spalten enthalten die Knotenindizes für den Anfang und das Ende der Kanten.

Es ist möglich, diesen Datenquellen zusätzliche Metadaten hinzuzufügen, um ein vektorisiertes Glyphen-Styling hinzuzufügen oder Daten für Callbacks oder Tooltips verfügbar zu machen.

Im Folgenden zeige ich ein Code-Snippet, das:

  • einen Knoten oval darstellt

  • height- und width-Attribute des Ovals festlegt

  • das fill_color-Attribut des Ovals festlegt

[1]:
import math

from bokeh.io import output_notebook, show
from bokeh.plotting import figure
output_notebook()
Loading BokehJS ...
[2]:
import math
from bokeh.plotting import figure
from bokeh.models import GraphRenderer, Oval
from bokeh.palettes import Spectral8

N = 8
node_indices = list(range(N))

plot = figure(title="Graph Layout Demonstration", x_range=(-1.1,1.1), y_range=(-1.1,1.1),
              tools="", toolbar_location=None)

graph = GraphRenderer()

graph.node_renderer.glyph = Oval(height=0.1, width=0.2, fill_color="fill_color")
graph.node_renderer.data_source.data = dict(
    index=node_indices,
    fill_color=Spectral8)

graph.edge_renderer.data_source.data = dict(
    start=[0]*N,
    end=node_indices)

Durch Ausführen des obigen Code-Snippets wird kein Diagramm gerendert, da wir nicht angegeben haben, wie das Diagramm im 2D-Raum angeordnet werden soll. Wie das geht, erfahren Sie im folgenden Abschnitt.

Layout Providers

Bokeh verwendet ein separates LayoutProvider-Modell, um die Koordinaten eines Graphen im kartesischen Raum anzugeben. Derzeit ist das StaticLayoutProvider-Modell der einzige integrierte Provider, der ein Wörterbuch mit (x,y)-Koordinaten für die Knoten enthält.

In unserem Beispiel wird dem obigen Codeausschnitt ein Provider hinzugefügt mit:

[3]:
from bokeh.models import GraphRenderer, StaticLayoutProvider, Oval
from bokeh.palettes import Spectral8

N = 8
node_indices = list(range(N))

p = figure(title='Graph Layout Demonstration', x_range=(-1.1,1.1), y_range=(-1.1,1.1),
           tools='', toolbar_location=None)

graph = GraphRenderer()

graph.node_renderer.data_source.add(node_indices, 'index')
graph.node_renderer.data_source.add(Spectral8, 'color')
graph.node_renderer.glyph = Oval(height=0.1, width=0.2, fill_color='color')

graph.edge_renderer.data_source.data = dict(
    start=[0]*N,
    end=node_indices)

### start of layout code
circ = [i*2*math.pi/8 for i in node_indices]
x = [math.cos(i) for i in circ]
y = [math.sin(i) for i in circ]

graph_layout = dict(zip(node_indices, zip(x, y)))
graph.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)

p.renderers.append(graph)

show(p)

Explizite Pfade

Standardmäßig werden vom StaticLayout-Provider gerade Pfade zwischen den angegebenen Knoten gezeichnet. Um explizite Kantenpfade bereitzustellen, können jedoch auch Listen mit Pfaden für die edge_renderer angegeben werden mit "xs" und "ys"-Spalten der ColumnDataSource. Beachtet jedoch, dass einerseits diese Pfade in derselben Reihenfolge sein sollten wie die Punkte "start" und "end" und andererseits, dass es keine Validierung gibt, die mit den Knotenpositionen übereinstimmt. Ihr solltet daher sehr vorsichtig sein, wenn ihr explizite Pfade festlegt.

Im folgenden Beispiel erweitern wir das obige Beispiel um quadratische Bezierpfade zwischen den Knoten:

[4]:
import math

from bokeh.io import show
from bokeh.plotting import figure
from bokeh.models import GraphRenderer, StaticLayoutProvider, Oval
from bokeh.palettes import Spectral8

N = 8
node_indices = list(range(N))

p = figure(title="Graph Layout Demonstration", x_range=(-1.1,1.1), y_range=(-1.1,1.1),
           tools="", toolbar_location=None)

graph = GraphRenderer()

graph.node_renderer.data_source.add(node_indices, 'index')
graph.node_renderer.data_source.add(Spectral8, 'color')
graph.node_renderer.glyph = Oval(height=0.1, width=0.2, fill_color="color")

graph.edge_renderer.data_source.data = dict(
    start=[0]*N,
    end=node_indices)

### start of layout code
circ = [i*2*math.pi/8 for i in node_indices]
x = [math.cos(i) for i in circ]
y = [math.sin(i) for i in circ]
graph_layout = dict(zip(node_indices, zip(x, y)))
graph.layout_provider = StaticLayoutProvider(graph_layout=graph_layout)

### Draw quadratic bezier paths
def bezier(start, end, control, steps):
    return [(1-s)**2*start + 2*(1-s)*s*control + s**2*end for s in steps]

xs, ys = [], []
sx, sy = graph_layout[0]
steps = [i/100. for i in range(100)]
for node_index in node_indices:
    ex, ey = graph_layout[node_index]
    xs.append(bezier(sx, ex, 0, steps))
    ys.append(bezier(sy, ey, 0, steps))
graph.edge_renderer.data_source.data['xs'] = xs
graph.edge_renderer.data_source.data['ys'] = ys

p.renderers.append(graph)

show(p)

NetworkX-Integration

Bokeh unterstützt das schnelle Zeichnen eines Netzwerkgraphen mit seiner NetworkX-Integration. bokeh.models.graphs.from_networkx akzeptiert ein networkx.Graph-Objekt und eine networkx-Layout-Methode, um eine konfigurierte GraphRenderer-Instanz zurückzugeben.

Hier ist ein Beispiel für die Verwendung der networkx.spring_layout-Methode zum Layout des in NetworkX integrierten Datensatzes karate_club_graph:

[5]:
import networkx as nx

from bokeh.io import show
from bokeh.plotting import figure
from bokeh.plotting import from_networkx

G=nx.karate_club_graph()

p = figure(title="Networkx Integration Demonstration", x_range=(-1.1,1.1), y_range=(-1.1,1.1),
           tools="", toolbar_location=None)

graph = from_networkx(G, nx.spring_layout, scale=2, center=(0,0))
p.renderers.append(graph)

show(p)

Interaktionen

Ihr könnt das Auswahl- oder Überprüfungsverhalten von Diagrammen konfigurieren, indem ihr die selection_policy- und inspection_policy-Attribute des GraphRenderer festlegt. Diese Attribute akzeptieren eine spezielle GraphHitTestPolicy-Instanz.

So kann beispielsweise selection_policy=NodesAndLinkedEdges() bewirken, dass mit einem ausgewählten Knoten auch die zugehörigen Kanten ausgewählt werden. In ähnlicher Weise werden durch inspection_policy=EdgesAndLinkedNodes() der Start- und Endknoten einer Kante auch beim Bewegen der Maus über eine Kante mit dem HoverTool überprüft.

Mit den selection_glyph-, nonselection_glyph- und hover_glyph-Attributen der Kanten- und Knotenrenderer können die Diagramminteraktionen auch dynamische visuelle Elemente hinzuzufügen.

Hier ist ein Beispiel mit solchen Knoten- und Kanteninteraktionen:

[6]:
import networkx as nx

from bokeh.io import show
from bokeh.models import Plot, Range1d, MultiLine, Circle, HoverTool, TapTool, BoxSelectTool
from bokeh.models.graphs import NodesAndLinkedEdges, EdgesAndLinkedNodes
from bokeh.plotting import from_networkx
from bokeh.palettes import Spectral4

G=nx.karate_club_graph()

p = Plot(plot_width=400, plot_height=400,
         x_range=Range1d(-1.1,1.1), y_range=Range1d(-1.1,1.1))
p.title.text = "Graph Interaction Demonstration"

p.add_tools(HoverTool(tooltips=None), TapTool(), BoxSelectTool())

graph_renderer = from_networkx(G, nx.circular_layout, scale=1, center=(0,0))

graph_renderer.node_renderer.glyph = Circle(size=15, fill_color=Spectral4[0])
graph_renderer.node_renderer.selection_glyph = Circle(size=15, fill_color=Spectral4[2])
graph_renderer.node_renderer.hover_glyph = Circle(size=15, fill_color=Spectral4[1])

graph_renderer.edge_renderer.glyph = MultiLine(line_color="#CCCCCC", line_alpha=0.8, line_width=5)
graph_renderer.edge_renderer.selection_glyph = MultiLine(line_color=Spectral4[2], line_width=5)
graph_renderer.edge_renderer.hover_glyph = MultiLine(line_color=Spectral4[1], line_width=5)

graph_renderer.selection_policy = NodesAndLinkedEdges()
graph_renderer.inspection_policy = EdgesAndLinkedNodes()

p.renderers.append(graph_renderer)

show(p)

Knoten- und Kantenattribute

In from_networkx werden die Knoten- und Kantenattribute von NetworkX für den node_rendereroder edge_renderer umgewandelt.

Das Dataset karate_club_graph hat beispielsweise ein Knotenattribut mit dem Namen club. Mit from_networkx istes möglich, diese Informationen zu verschieben. In ähnlicher Weise können auch weitere Knoten- und Kantenattribute für Farbinformationen verwendet werden:

[7]:
import networkx as nx

from bokeh.io import show
from bokeh.models import Plot, Range1d, MultiLine, Circle, HoverTool, BoxZoomTool, ResetTool
from bokeh.plotting import from_networkx
from bokeh.palettes import Spectral4

# Prepare Data
G = nx.karate_club_graph()

SAME_CLUB_COLOR, DIFFERENT_CLUB_COLOR = "black", "red"
edge_attrs = {}

for start_node, end_node, _ in G.edges(data=True):
    edge_color = SAME_CLUB_COLOR if G.nodes[start_node]["club"] == G.nodes[end_node]["club"] else DIFFERENT_CLUB_COLOR
    edge_attrs[(start_node, end_node)] = edge_color

nx.set_edge_attributes(G, edge_attrs, "edge_color")

# Show with Bokeh
p = Plot(plot_width=400, plot_height=400,
         x_range=Range1d(-1.1, 1.1), y_range=Range1d(-1.1, 1.1))
p.title.text = "Graph Interaction Demonstration"

node_hover_tool = HoverTool(tooltips=[("index", "@index"), ("club", "@club")])
p.add_tools(node_hover_tool, BoxZoomTool(), ResetTool())

graph_renderer = from_networkx(G, nx.spring_layout, scale=1, center=(0, 0))

graph_renderer.node_renderer.glyph = Circle(size=15, fill_color=Spectral4[0])
graph_renderer.edge_renderer.glyph = MultiLine(line_color="edge_color", line_alpha=0.8, line_width=1)
p.renderers.append(graph_renderer)

show(p)