Analyzing multigraphs in Neo4j Graph data science library | by Tomaz Bratanic | Towards Data Science

When times are tough, I think it is essential to focus on our relationships. Some of us concentrate more on social interactions. Others like to play around with neurons, while some just want to look at cute animals. No matter your network preference, I would like to help you reflect on those relationships and find more (positive) insights about them. To do that, we will put on our data science hat and examine a simple network to learn how does the Neo4j Graph data science library deal with multigraphs and how to analyze them. I have to warn you that this will be a longer blog post and focused more on the technical details.
What is a multigraph anyway? Let’s look at the Wikipedia definition:
In mathematics, and more specifically in graph theory, a multigraph is a graph which is permitted to have multiple edges (also called parallel edges), that is, edges that have the same end nodes. Thus two vertices may be connected by more than one edge.
There are two distinct notions of multiple edges:
- Edges without own identity: The identity of an edge is defined solely by the two nodes it connects. In this case, the term “multiple edges” means that the same edge can occur several times between these two nodes.
- Edges with own identity: Edges are primitive entities just like nodes. When multiple edges connect two nodes, these are different edges.
To summarize the definition, the multigraph allows multiple relationships between a given pair of nodes. In other words, it means that we can have many connections of the same type between a pair of nodes like:
Or connections with different types between a pair of nodes like:
For example, in a knowledge graph, we might run into a combination of both.
We will use the above example graph to demonstrate how the GDS library handles projecting multigraphs, what to look for, and what to expect. I have added weights to relationships as we will need them to demonstrate property aggregations, but more on that later.
CREATE (t:Entity{name:'Tomaz'}),       (n:Entity{name:'Neo4j'})CREATE (t)-[:LIKES{weight:1}]->(n),       (t)-[:LOVES{weight:2}]->(n),       (t)-[:PRESENTED_FOR{weight:0.5}]->(n),       (t)-[:PRESENTED_FOR{weight:1.5}]->(n);
You can expect a deep dive into GDS multigraph projections, and little to no focus on the actual graph algorithms. We will use only the Degree centrality to examine the projected graphs.
In the context of the GDS library, relationships without their own identity imply that we ignore the type of relationships in the process of projecting the graph.
We will start with native projection examples. If we use the wildcard operator * to define the relationships we want to project, we ignore their type and bundle them all together. This can be understood as losing their own identity (type in the context of Neo4j).
In the first example, we will observe the default behavior of the graph projection process.
CALL gds.graph.create('default_agg','*','*',     {relationshipProperties: ['weight']})
The default aggregation strategy doesn’t perform any aggregations and projects all the relationships from the stored graph to memory without any transformations. If we check the relationshipCount, we observe that four relationships have been projected. We can also take a look at the relationshipProjection:
{  "*": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "*",    "properties": {      "weight": {        "property": "weight",        "defaultValue": null,        "aggregation": "DEFAULT"      }    }  }}
Anytime you see a type:'*', you can be sure that all relationships have lost their type in the projection process. This also means that we can't perform additional filtering when executing the algorithms. To double-check the projected graph, we can use the degree centrality.
CALL gds.alpha.degree.stream('default_agg')YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,        score AS degreeORDER BY degree DESC
Results
╔═══════════╦════════════╗║   name    ║   degree   ║╠═══════════╬════════════║║ Tomaz     ║   4.0      ║║ Neo4j     ║   0.0      ║╚═══════════╩════════════╝
As we expected, all four relationships have been projected. To have a reference for the future, let’s also calculate the weighted degree centrality. By adding therelationshipWeightProperty parameter, we indicate we want to use the weighted variant of the algorithm.
CALL gds.alpha.degree.stream('default_agg',     {relationshipWeightProperty:'weight'})YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,        score AS weighted_degreeORDER BY weighted_degree DESC
Results
╔═══════════╦════════════════════╗║   name    ║   weighted_degree  ║╠═══════════╬════════════════════║║ Tomaz     ║        4.0         ║║ Neo4j     ║        0.0         ║╚═══════════╩════════════════════╝
The result is the sum of weights of all the considered relationships. We have no use of this projected graph anymore, so remember to release it from memory.
CALL gds.graph.drop('default_agg');
Depending on the use case, we might want to reduce our multigraph to a single graph during the projection process. This can be easily achieved with the aggregation parameter. We have to use the configuration map variant for the relationship definition.
CALL gds.graph.create('single_rel_strategy','*',   {TYPE:{type:'*', aggregation:'SINGLE'}})
We notice by looking at the relationshipCount, that only a single relationship has been projected. If we want, we can double-check the results with the degree centrality.
CALL gds.alpha.degree.stream('single_rel_strategy')YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,       score AS degreeORDER BY degree DESC
Results
╔═══════════╦════════════╗║   name    ║   degree   ║╠═══════════╬════════════║║ Tomaz     ║   1.0      ║║ Neo4j     ║   0.0      ║╚═══════════╩════════════╝
Don’t forget to drop the projected graph once we are done.
CALL gds.graph.drop('single_rel_strategy');
We have looked at the unweighted multigraph so far. Now it is time to look at what happens when we are dealing with a weighted multigraph, and we want to reduce it to a single graph. There are three different strategies we can pick for property aggregation strategy:
MIN: minimum value of all weights is projected
MAX: maximum value of all weights is projected
SUM: the sum of all weights is projected
In our next example, we will use the MIN property aggregation strategy to reduce a weighted multigraph to a single graph. By providing the property aggregation parameter, we indicate we want to reduce the stored graph to a single graph in the projection process.
CALL gds.graph.create('min_aggregation','*','*',    {relationshipProperties: {weight: {property: 'weight',                                       aggregation: 'MIN'}}})
We can observe that the relationshipCount is 1, which means our multigraph has been successfully reduced to a single graph. Let's examine the relationshipProjection.
{  "*": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "*",    "properties": {      "weight": {        "property": "weight",        "defaultValue": null,        "aggregation": "MIN"      }    }  }}
Here we can observe that there are two aggregation configuration options, one on the relationship level and one on the property level. As far as I can tell, you should use the relationships level aggregation when dealing with unweighted networks and property level aggregation when dealing with weighted ones. We will again double-check the results with the degree centrality.
CALL gds.alpha.degree.stream('min_aggregation')YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,       score AS degreeORDER BY degree DESC
Results
╔═══════════╦════════════╗║   name    ║   degree   ║╠═══════════╬════════════║║ Tomaz     ║   1.0      ║║ Neo4j     ║   0.0      ║╚═══════════╩════════════╝
To validate the MIN property aggregation, let's also calculate the weighted degree centrality.
CALL gds.alpha.degree.stream('min_aggregation',    {relationshipWeightProperty:'weight'})YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,       score AS weighted_degreeORDER BY weighted_degree DESC
Results
╔═══════════╦════════════════════╗║   name    ║   weighted_degree  ║╠═══════════╬════════════════════║║ Tomaz     ║        0.5         ║║ Neo4j     ║        0.0         ║╚═══════════╩════════════════════╝
As we expected with the MIN property aggregation strategy, the single reduced weight has the minimum value of considered weights. Again, as we finished with the example, don't forget to drop the projected graph.
CALL gds.graph.drop('min_aggregation');
Let’s recreate the above examples with cypher projection. To lose the identity of the relationships and bundle them all together, we avoid providing the type column in the return of the relationship statement.
Similarly to native projection, the default setting in cypher projection is to project all the relationships without any transformation during the projection process.
CALL gds.graph.create.cypher(  'cypher_default_strategy',  'MATCH (n:Entity) RETURN id(n) AS id',  'MATCH (n:Entity)-[r]->(m:Entity)   RETURN id(n) AS source, id(m) AS target')
By looking at the relationshipCount, we observe that all four relationships have been projected as intended. Let's also take a close look at the relationshipProjection.
{  "*": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "*",    "properties": {     }  }}
Remember, before we said that the type:"*" indicates that relationships lose their identity (type) during the projection process. The same applies to cypher projection. To verify the projected graph, we run the degree centrality.
CALL gds.alpha.degree.stream('cypher_default_strategy')YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,        score AS degreeORDER BY degree DESC
Results
╔═══════════╦════════════╗║   name    ║   degree   ║╠═══════════╬════════════║║ Tomaz     ║   4.0      ║║ Neo4j     ║   0.0      ║╚═══════════╩════════════╝
With cypher projection, we don’t have access to relationship level aggregation strategies. This is no problem at all, as it is very straightforward to reduce the multigraph to a single graph using only the cypher query language. We simply add the DISTINCT clause in the return of the relationship statement, and it should be good to go. If you need more help with cypher, I suggest you take a look at Neo4j Graph Academy.
CALL gds.graph.create.cypher(  'cypher_single_strategy',  'MATCH (n:Entity) RETURN id(n) AS id',  'MATCH (n:Entity)-[r]->(m:Entity)    RETURN DISTINCT id(n) AS source, id(m) AS target')
The relationship count is 1, which means we have successfully reduced the multigraph. Remember to drop the projected graph.
CALL gds.graph.drop('cypher_single_strategy')
On the other hand, with cypher projection, we do have access to property level aggregation strategies. We don’t really “need” them as we can accomplish all the transformation using only cypher. To show you what I mean by that, we can apply the minimum property strategy aggregation using plain cypher like:
CALL gds.graph.create.cypher(  'cypher_min_strategy',  'MATCH (n:Entity) RETURN id(n) AS id',  'MATCH (n:Entity)-[r]->(m:Entity)   RETURN id(n) AS source, id(m) AS target, min(r.weight) as weight')
However, if we look at the official documentation:
One drawback of that approach is that we put more pressure on the Cypher execution engine and the query result consumes additional memory. An alternative approach is to use relationshipProperties as part of the optional configuration map. The syntax is identical to the property mappings used in the native projection.
So, to conserve memory, we can use the property level aggregation strategies in the configuration map.
CALL gds.graph.create.cypher(  'cypher_min_improved',  'MATCH (n:Entity) RETURN id(n) AS id',  'MATCH (n:Entity)-[r]->(m:Entity)   RETURN id(n) AS source, id(m) AS target, r.weight as weight',   {relationshipProperties: {minWeight: {property: 'weight',                                          aggregation: 'MIN'}}})
The relationshipCount is 1, which confirms our successful multigraph reduction. Just to make sure, we can run the weighted centrality and validate results.
CALL gds.alpha.degree.stream('cypher_min_improved',    {relationshipWeightProperty:'minWeight'})YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,        score AS weighted_degreeORDER BY weighted_degree DESC
Results
╔═══════════╦════════════════════╗║   name    ║   weighted_degree  ║╠═══════════╬════════════════════║║ Tomaz     ║        0.5         ║║ Neo4j     ║        0.0         ║╚═══════════╩════════════════════╝
With everything in order, we can release both projected graphs from memory.
CALL gds.graph.drop('cypher_min_improved');CALL gds.graph.drop('cypher_min_strategy);
We also have the option to retain the type of relationships during the projection process. Among other things, this allows us to perform additional filtering when executing graph algorithms. However, we have to be careful, as projecting relationships with a preserved type is a bit different in the context of multigraphs.
With the native projection, it is simple to declare that we want to preserve the type of relationships. All we have to do is specify which relationship types we want to consider, and the GDS engine will automatically bundle relationships under the specific relationship type. Let’s take a look at some examples to gain a better understanding.
From previous examples we already know that the default aggregation strategy does not perform any transformations. By defining the relationship types, we indicate to the GDS library that we want to retain their type after the projection process.
CALL gds.graph.create('type_default','*',    ['PRESENTED_FOR','LIKES','LOVES'])
As expected, therelationshipsCount is 4. Let's take a closer look at the relationshipProjection.
{  "LIKES": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "LIKES",    "properties": {}  },  "LOVES": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "LOVES",    "properties": {}  },  "PRESENTED_FOR": {    "orientation": "NATURAL",    "aggregation": "DEFAULT",    "type": "PRESENTED_FOR",    "properties": {}  }}
We can see that we have three different bundles or groups of relationships. Each bundle composes of a single relationship type, which is defined with the type parameter. It is handy to look at relationshipProjection to validate what kind of graph we have projected and if the relationship types have been retained. Not much else to do with this in-memory graph.
CALL gds.graph.drop('type_default');
Like before, we can reduce our unweighted multigraph to a single graph with the relationship level aggregation parameter. We have to provide the aggregation parameter for each relationship type separately.
CALL gds.graph.create('type_single','*',   {LIKES:{type:'LIKES',aggregation:'SINGLE'},    LOVES:{type:'LOVES',aggregation:'SINGLE'},    PRESENTED_FOR:{type:'PRESENTED_FOR',aggregation:'SINGLE'}})
Ok, so we reduced to a single graph, but the relationshipCount is 3. Why is it so? The multigraph reduction process works on the relationship type level, and because we have three distinct relationship types, a single relationship for each type has been projected. Let's calculate the degree centrality on the whole in-memory graph.
CALL gds.alpha.degree.stream('type_single')YIELD nodeId, score RETURN gds.util.asNode(nodeId).name AS name,        score AS degreeORDER BY degree DESC
Results
╔═══════════╦════════════╗║   name    ║   degree   ║╠═══════════╬════════════║║ Tomaz     ║   3.0      ║║ Neo4j     ║   0.0      ║╚═══════════╩════════════╝
As we explained, even though we have reduced each relationship type separately, we are still dealing with a multigraph on the whole. When running graph algorithms, you have to pay close attention to whether you are dealing with a multigraph or not, have you projected multiple relationship types and have you performed any transformations during projection, as all of this will affect the algorithm results.
We can now drop this graph.
CALL gds.graph.drop('type_single');
Property aggregation strategies are very similar to before when we were dealing with relationships without identity. The only change is that now the aggregations are grouped by the relationship type.
CALL gds.graph.create('type_min','*',    ['PRESENTED_FOR','LIKES','LOVES'],    {relationshipProperties: {weight: {property: 'weight',                                        aggregation: 'MIN'}}})
We get 3 relationships projected as we have learned that the aggregations happen on the relationship type level. We will double-check the results with the weighted degree.
CALL gds.alpha.degree.stream('type_min',    {relationshipWeightProperty:'weight'})YIELD nodeId, scoreRETURN gds.util.asNode(nodeId).name AS name,        score AS weighted_degreeORDER BY weighted_degree DESC
Results
╔═══════════╦════════════════════╗║   name    ║   weighted_degree  ║╠═══════════╬════════════════════║║ Tomaz     ║        3.5         ║║ Neo4j     ║        0.0         ║╚═══════════╩════════════════════╝
I feel like a broken record by now, but don’t forget to drop the graph :)
CALL gds.graph.drop('type-min');
I skipped the examples for cypher projection with preserved relationship types to shorten this blog post a bit. Basically, all you have to do is to add the column type in the relationship statement, and it should behave identically to the native projection examples. I hope you got a better understanding of what is going on under the hood of the Neo4j Graph Data Science library during the projection process, which will hopefully help you find more and better insights. To sum it up, all aggregations either on relationship level or property level are grouped by the relationship type.
Thanks for reading, and as always, the code is available on GitHub.