Usage#
Welcome to the usage documentation! This page covers the rudiments of the
Graph
object and underlying constructs of
Node
and Edge
objects.
The usage of the different analysis methods and other subjects is discussed in the following documentation sections:
Create a Graph#
Let’s start by building a graph! A graph consists of nodes and edges, sometimes
respectively called vertices and arcs but we use the former. We can start with an empty
Graph
object:
>>> from ragraph.graph import Graph
>>> g = Graph()
You can slowly populate the empty Graph
object or load nodes and
edges in bulk, both of which we’ll see later this tutorial.
Add a Node#
When creating a Node
, all we need is a name. Let’s create a node
called A
.
>>> from ragraph.node import Node
>>> a = Node("A")
>>> g.add_node(a)
>>> g["A"]
<ragraph.node.Node(name='A', parent=None, children=[], is_bus=False, kind='node', labels=['default'], weights={'default': 1}, annotations=Annotations({}), uuid=UUID(...)) at 0x...>
What you see here, is that we create a Node
object and add it to
the Graph
. We can fetch the node from the graph via its
name
, which has to be unique within the
Graph
. Also, there are quite some attributes attached to the
Node
by default. These are mostly metadata which you can safely
ignore for now. The important thing is that it got our name right!
Add an Edge#
An edge runs from a source node to a target node, which means that it is directed. Those
two nodes are the only required parameters to create one! Lets create a second node
called B
and an edge running from A
to B
.
>>> from ragraph.edge import Edge
>>> b = Node("B")
>>> g.add_node(b)
>>> ab = Edge(a, b)
>>> g.add_edge(ab)
>>> g["A", "B"]
[<ragraph.edge.Edge(source=Node(name='A'), target=Node(name='B'), name='...', kind='edge', labels=['default'], weights={'default': 1}, annotations=Annotations({}), uuid=UUID(...)) at 0x...>]
So that concludes our first Edge
! You can query all edges between
two nodes (you can add any amount!) by supplying both a source and target
Node
name as a tuple. Again, the same metadata properties have been
added as we’ve seen before, which you can safely ignore.
Create a hierarchical Graph#
Suppose we want to create a hierarchial Graph
where nodes have
parent-child relationships. You can!
>>> g = Graph(nodes=[Node(i) for i in "ABCD"]) # Creates four nodes.
>>> g["A"].children = [g[i] for i in "BCD"] # Set children of "A".
>>> g["B"] in g["A"].children
True
Which means the children have been added to A
’s children property. The parent
relationship is updated automatically, too, though!
>>> g["B"].parent == g["A"]
True
It’s perfectly possible to add edges to hierarchical graphs. There are no restrictions as to which source nodes can target which target nodes, as long as both exist in the graph.
Note
Some algorithms leverage this when calculating weights between nodes, so make sure you understand how the weights or relations between nodes are calculated in an algorithm so you provide it with the correct input.
Using the metadata fields#
In RaGraph, both Node
and Edge
objects support
an identical metadata structure. This structure consists of the following elements:
kind
: The main category or domain of a node or edge.
labels
: A list of labels you can to attach to any node or edge.
weights
: A dictionary of keys to (numeric) values. For instance acost
property for a node or thestrength
of an edge.
annotations
: A rather flexibleragraph.generic.Annotations
object you can store pretty much any additional information in. You can initialize it using a dictionary as you will see in the following example.
An example of a fully annotated Node
or Edge
would then be:
>>> fancy_node = Node(
... name="fancy node",
... kind="exquisite",
... labels=["grand", "grotesque"],
... weights={"cost": 1e6},
... annotations={"comment": "Some additional information."},
... )
>>> fancy_edge = Edge(
... source=fancy_node,
... target=fancy_node,
... name="fancy edge",
... kind="exquisite",
... labels=["grand", "grotesque"],
... weights={"cost": 1e6},
... annotations={"comment": "Some additional information."},
... )
>>> fancy_graph = Graph(nodes=[fancy_node], edges=[fancy_edge])
>>> print(fancy_node.annotations.comment)
Some additional information.
Where most properties are fairly explanatory, the Annotations
object might need a little explaining. It’s essentially a class you can supply any
(keyword) arguments or a dictionary to. The keys are used to form property names.
Keep in mind that it is recommended to only add serializable objects to this class, so
you can export and import your data with ease.
As a dictionary#
All of the Node
, Edge
,
Annotations
, and Graph
classes feature
a json_dict
property which is a regular Python dictionary containing only
serializable data that’s readily exportable using the default json
module or most
other Python I/O packages.
Let’s review the it for some of the previously introduced variables:
>>> import json
>>> print(json.dumps(fancy_node.as_dict(), indent=4))
{
"name": "fancy node",
"parent": null,
"children": [],
"is_bus": false,
"kind": "exquisite",
"labels": [
"grand",
"grotesque"
],
"weights": {
"cost": 1000000.0
},
"annotations": {
"comment": "Some additional information."
}
}
>>> print(json.dumps(fancy_edge.as_dict(), indent=4))
{
"source": "fancy node",
"target": "fancy node",
"name": "fancy edge",
"kind": "exquisite",
"labels": [
"grand",
"grotesque"
],
"weights": {
"cost": 1000000.0
},
"annotations": {
"comment": "Some additional information."
}
}
Note
See for yourself how this works with the other objects!
Node properties#
Nodes have specific properties such as width
,
depth
, and height
to name a few.
These often come into play when analyzing hierarchies of nodes using clustering or bus detection algorithms.
A short summary of most of the available properties:
width
: The number of children this node has.
depth
: The number of consecutive ancestor (parent) nodes up until the root node. If this is a root node, the depth is 0.
height
: The maximum number of consecutive descendant (child) nodes until a leaf node is reached. If this is a leaf node, the height is 0.
is_leaf
: Whether this node has no children and thus is a leaf node.
is_root
: Whether this node is a root node and thus has no parent.
is_bus
: Whether this node is a highly integrative node within its network of siblings. See Bus detection.
Graph utilities#
Up until now we’ve more or less treated the Graph
object as a
Node
and Edge
store but it is more than that!
The Graph
object has several useful methods to:
Get the used kinds, labels, and weights for nodes and edges.
Kinds, labels, weights#
To check what kinds, labels, or weights have been used, you can use any of the following
properties on any Graph
object:
Querying nodes#
So far, we’ve discussed getting nodes by name using graph["node name"]
. However, you
can use any of the following methods to retrieve specific nodes as well:
roots
: Get all nodes in the graph that have no parent.
leafs
: Get all nodes in the graph that have no children.
targets_of
: Yield all nodes that have an incoming edge from your given node.
sources_of
: Yield all nodes that have an edge targeting your given node.
Querying edges#
Previously, we have retrieved edges using graph["source name", "target name"]
or via
their edge ID using graph.id_to_edge[id]
. Similarly to the nodes, we have the following methods to retrieve specific edges:
edges_from
: Yield all edges originating from your given node.
edges_to
: Yield all edges that target your given node.
edges_between
: Yield all edges between two nodes.
edges_between_all
: Yield all edges between a set of sources and targets.
Calculate an adjacency matrix#
An adjacency matrix represents the sum of edge weights between sets of nodes. The nodes
are identically ordered on both the matrix’ axes. A cell value on [i, j]
then
denotes the sum of edge weights of edges going from the j
node (column) to the i
node (row). This follows the IR/FAD (inputs in rows, feedback above diagonal)
convention. Here is a little example of the
get_adjacency_matrix
method:
>>> a = Node("a")
>>> b = Node("b")
>>> nodes = [a, b]
>>> edges = [
... Edge(a, a, weights={"strength": 1}),
... Edge(b, a, weights={"flow": 3}),
... Edge(a, b, weights={"strength": 9}),
... ]
>>> g = Graph(nodes=nodes, edges=edges)
>>> g.get_adjacency_matrix(loops=True)
array([[1., 3.],
[9., 0.]])
Or, if you want to omit self loops and only look at the flow
weights:
>>> g.get_adjacency_matrix(loops=False, only=["flow"])
array([[0., 3.],
[0., 0.]])
Please take a look at the method’s documentation for more information:
get_adjacency_matrix
.
Note
Similarly, you can calculate a mapping matrix using
get_mapping_matrix
where the rows and columns do not
need to be symmetrical. This is commonly used to calculate a mapping from nodes of
one domain (node kind) to another.