Understanding Aidge Intermediate Representation (IR)#
In this notebook we are going to dive into Aidge intermediate representation.
The Aidge framework represents a Deep Neural Network (DNN) model as a directed computational graph, where vertices correspond to computational operations and edges define the flow of data between these operations.
The graph is explicitly defined by the user and is not inferred from code.
At the end of it you will gain knowledge on how Aidge define a Graph and how you can manipulate it.
[1]:
import aidge_core
Operator#
Aidge Operator are a data structure that characterize an operation.
For example we can instantiate the LeakyReLU Operator like so:
[2]:
leaky_relu_operator = aidge_core.LeakyReLUOp(negative_slope=1)
This operator is characterized by an attribute which defines the probability of setting to zero some element of the input Tensor. One can see the attribute of an operator using the attr attribute:
[3]:
leaky_relu_operator.attr
[3]:
AttrDict({'negative_slope': 1.0})
An Operator is only a data structure and does not have an Implementation. If a user want to infer, learn or generate code with his graph, he will have to give an implementation, which he can do that using the setBackend function. We will however stick to the IR in this tutorial and thus will not see how to do this.
Something that is interesting to note is that because Operator does not have an implementation, we can introduce the concept of a GenericOperator. This Operator a simple shell that can represent any Operator. Such an Operator allow to load any operator that is not defined in the Aidge framework and let the user defines the behavior of such operator.
[4]:
gen_op = aidge_core.GenericOperatorOp(
type="CustomOp",
nb_data=1,
nb_param=0,
nb_outputs=1
)
print(gen_op)
Operator(type = 'CustomOp', nb_in = 1, nb_out = 1, attr = AttrDict({}), backend = '')
We can then add any attributes we want using the following functions:
[5]:
if not gen_op.attr.has_attr("test"):
gen_op.attr.add_attr("test", True)
else:
gen_op.attr.set_attr("test", False)
print(gen_op.attr)
AttrDict({'test': True})
The Operator also hold a reference to the Inputs/Outputs tensors. Which one can get using get_output() function:
[6]:
print(f"DropOut operator input: {leaky_relu_operator.get_input(0)}") # get the 0th input of the dropout operator.
print(f"DropOut operator output: {leaky_relu_operator.get_output(0)}") # get the 0th output of the dropout operator.
DropOut operator input: None
DropOut operator output: []
Node#
It is the Node that carry the topological information of the graph. Each node maintains references to its outgoing and incoming edges, which connect it to their children and parents nodes, respectively. These connections represent the data flowing between nodes. The term data is deliberately broad as it can be specialized into different types, such as tensors, sparse tensors or spikes.
A Node must possess an Operator, one can create a Node using:
[7]:
leaky_relu_node = aidge_core.Node(leaky_relu_operator)
print(leaky_relu_node)
Node(name='', optype='LeakyReLU', parents: [0], children: [[]])
Always creating a Nodes and its associated Operators can be cumbersome. This is why Aidge introduces factory functions that allow to directly create an Operator embedded in a Node. This factory function have the Operator name without the suffix Op which is reserved for the actual Operator object.
Creating a LeakyReLU Operator in a Node would written like this:
[8]:
leaky_relu_node = aidge_core.LeakyReLU(negative_slope=0.1, name="myLeakyReLU")
print(leaky_relu_node)
print(leaky_relu_node.get_operator()) # <- Equivalent to out previous leaky_relu_operator
Node(name='myLeakyReLU', optype='LeakyReLU', parents: [0], children: [[]])
Operator(type = 'LeakyReLU', nb_in = 1, nb_out = 1, attr = AttrDict({'negative_slope': 0.10000000149011612}), backend = None)
As stated above, the node contains the neighbors informations:
[9]:
print(leaky_relu_node.get_parents())
print(leaky_relu_node.get_children())
[None]
set()
And we can connect node between each over:
[10]:
node_0 = aidge_core.ReLU(name="relu0")
leaky_relu_node.add_child(node_0)
print(node_0.get_parents())
print(leaky_relu_node.get_children())
[Node(name='myLeakyReLU', optype='LeakyReLU', parents: [0], children: [[1]])]
{Node(name='relu0', optype='ReLU', parents: [1], children: [[]])}
GraphView#
These two object already defines a Graph, however since we do not have a graph object it would be difficult to perform operation on the whole graph at once.
This is why we introduce the notion of GraphView as implied by its name this object is only a view on the graph.
[11]:
graph_view = aidge_core.GraphView()
graph_view.add(node_0)
graph_view.add(leaky_relu_node)
print(graph_view)
GraphView(name='', Nodes: 2 (inputs: 1, outputs: 1))
This allow for example to set in one line the dataype of both node:
[12]:
graph_view.set_datatype(aidge_core.dtype.float32)
You can insert a new node to the graph using add_child() method
[13]:
node_1 = aidge_core.ReLU(name="relu1")
graph_view.add_child(node_1)
print(graph_view)
GraphView(name='', Nodes: 3 (inputs: 1, outputs: 1))
⚠️ It is important to note that connecting Node between them does not add them to a GraphView
[14]:
print(f"Before connecting nodes, graphview has {len(graph_view.get_nodes())} nodes.")
node_2 = aidge_core.ReLU(name="relu2")
node_1.add_child(node_2)
print(f"After connecting nodes, graphview has {len(graph_view.get_nodes())} nodes.")
Before connecting nodes, graphview has 3 nodes.
After connecting nodes, graphview has 3 nodes.
If you want to add node_2 to the graph you need to call the add() method as follow:
[15]:
graph_view.add(node_2)
print(f"And now, graphview has {len(graph_view.get_nodes())} nodes.")
And now, graphview has 4 nodes.
Connector#
Aidge supports a functional style using Connectors. Nodes can be called like functions: they consume Connectors as inputs and return new ones.
[16]:
x = aidge_core.Connector()
y = aidge_core.ReLU()(aidge_core.FC(784, 300)(x))
Or step by step :
[17]:
x = aidge_core.Connector()
x = aidge_core.FC(784, 300)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(300, 10)(x)
gv = aidge_core.generate_graph([x])
print(f"The graph has {len(gv.get_nodes())} nodes.")
The graph has 7 nodes.
A node can also be called with several connectors. Each connector is mapped to one of the node’s inputs.
⚠️ If you pass more connectors than the node has inputs, a runtime error will occur.
[18]:
n1 = aidge_core.GenericOperator("type", 1, 0, 1, "op1")
n2 = aidge_core.GenericOperator("type", 1, 0, 1, "op2")
n3 = aidge_core.GenericOperator("type", 2, 0, 1, "op3") # Node with 2 inputs
x = aidge_core.Connector()
x = n3(n1(x),n2(x))
print(n3.get_parents())
print(n3.get_parent(0))
print(n3.get_parent(1))
[Node(name='op1', optype='type', parents: [0], children: [[1]]), Node(name='op2', optype='type', parents: [0], children: [[1]])]
Node(name='op1', optype='type', parents: [0], children: [[1]])
Node(name='op2', optype='type', parents: [0], children: [[1]])
Once you have defined your graph using connectors, you can generate a GraphView to manipulate the graph as a whole:
[19]:
x = aidge_core.Connector()
x = aidge_core.FC(784,300)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(300,100)(x)
x = aidge_core.ReLU()(x)
x = aidge_core.FC(100,10)(x)
gv = aidge_core.generate_graph([x])
print(f"The new graph view has {len(gv.get_nodes())} nodes.")
The new graph view has 11 nodes.
Graph Helpers#
For even simpler construction, use the sequential helper. It automatically connects the first output of each node to the first input of the next.
[20]:
sequentialGraphView = aidge_core.sequential([
aidge_core.FC(784, 300, name="fc0"),
aidge_core.ReLU(),
aidge_core.FC(300, 100, name="fc1"),
aidge_core.ReLU(),
aidge_core.FC(100, 10, name="fc2"),
])
print(f"The new graph view has {len(sequentialGraphView.get_nodes())} nodes.")
The new graph view has 11 nodes.