Skip to content

Create a graph

The semantics.reasoners.graph module is used to build graphs from your semantic model so that you can compute graph algorithms and use their outputs in your model to drive analysis and make business decisions. This guide covers the basics of creating graphs.

A graph is a way to represent a set of things and the connections between them. The things are called nodes. The connections are called edges.

This sounds a bit like what you already do with concepts and relationships in a semantic model, and it is! The main difference is that a graph views nodes and edges as part of a single structure that you can use to run graph algorithms.

The Graph class is the container for a graph.

  • Nodes: The set of things that count as nodes for this question. In PyRel graph reasoning, nodes are instances of graph.Node.
  • Edges: The set of directed or undirected connections between nodes. Edges are instances of graph.Edge.
  • Optional edge weights: A non-negative numeric value per edge.
  • Graph algorithms: Methods that compute properties of the graph structure, like connectivity, centrality, similarity, and communities.

Graphs in PyRel are:

  • Labeled property graphs: Nodes and edges can have properties like id, name, or amount.
  • Simple graphs: There can only be one edge in a given direction between any two nodes. If your data has multiple connections between the same nodes, you will get an error whenever you try to run an algorithm unless you collapse those multi-edges using the aggregator argument.

The most common workflow is to use the concepts and relationships you already have in your model to define a graph’s structure and then run algorithms on that structure to derive new facts that you can join back into your model for analysis.

In the rest of this guide, you will learn various techniques for defining a graph from the concepts and relationships in your semantical model.

When you create a Graph object, you must choose the type of graph you want to create. Graphs can be:

  • Directed: Each edge has an arrow from src to dst. For example, a payment edge might point from payer → payee.
  • Undirected: Edges do not have arrows. You can treat a link between two nodes as going both ways.
  • Weighted: Each edge has a number called a weight. For example, that weight might be payment amount or interaction frequency.
  • Unweighted: Edges do not have weights. The graph only cares whether a connection exists.

Which type of graph you create is determined by the directed and weighted arguments you are required to pass when you construct the Graph object.

Use this table to help you pick which type of to create:

What to useWhen to use it
Graph(m, directed=True, weighted=False)Use when direction matters, but weights would mislead, like follower → followed relationships.
Graph(m, directed=False, weighted=False)Use when links are mutual and only link existence matters, like friendship or collaboration.
Graph(m, directed=False, weighted=True)Use when links are mutual and weights matter, like co-authorship with number of joint papers as weight.
Graph(m, directed=True, weighted=True)Use when direction and weights matter, like payer → payee payments with amount as weight.
  • In weighted graphs, every edge must have a weight. Graph algorithms assume weights are non-negative.

Create an undirected graph by setting directed=False when you construct the Graph object:

from relationalai.semantics import Integer, Model
from relationalai.semantics.reasoners.graph import Graph
m = Model("UndirectedGraph")
# Declare the model schema.
Account = m.Concept("Account", identify_by={"id": Integer})
Person = m.Concept("Person", identify_by={"id": Integer})
Account.owner = m.Relationship(f"{Account} is owned by {Person}")
Person.account = Account.owner.alt(f"{Person} owns {Account}")
# Define base facts.
13 collapsed lines
people_data = m.data([
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
])
m.define(Person.new(people_data.to_schema()))
account_data = m.data([
{"id": 101, "owner_id": 1},
{"id": 102, "owner_id": 2},
{"id": 103, "owner_id": 2},
])
account_owner = Person.filter_by(id=account_data.owner_id)
m.define(Account.new(id=account_data.id, owner=account_owner))
# Create an undirected, unweighted graph.
graph = Graph(m, directed=False, weighted=False)
# Define the edges of the graph.
m.define(graph.Edge.new(src=Person, dst=Person.account))
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • Graph(m, directed=False, weighted=False) creates an undirected, unweighted graph.
  • graph.Edge.new(src=Person, dst=Person.account) derives edges from the Person.account relationship. Each edge connects a Person node to the Account node they own.
  • graph.num_nodes().inspect() and graph.num_edges().inspect() are quick sanity checks to validate the graph structure.
  • Defining edges from a relationship like Person.account is a common pattern when your model already has a relationship that maps directly to the connections you want in your graph.
  • For other patterns for edge definition, see Define nodes with node_concept.

To create a directed graph, set directed=True when you construct the Graph object:

from relationalai.semantics import Integer, Model
from relationalai.semantics.reasoners.graph import Graph
m = Model("DirectedGraph")
# Declare the model schema.
Person = m.Concept("Person", identify_by={"id": Integer})
Account = m.Concept("Account", identify_by={"id": Integer})
Account.owner = m.Property(f"{Account} owned by {Person}")
Transaction = m.Concept("Transaction", identify_by={"id": Integer})
Transaction.payer = m.Property(f"{Transaction} received from {Account:payer}")
Transaction.payee = m.Property(f"{Transaction} payed to {Account:payee}")
# Define base facts.
22 collapsed lines
person_data = m.data([
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
])
m.define(Person.new(person_data.to_schema()))
account_data = m.data([
{"id": 1, "owner_id": 1},
{"id": 2, "owner_id": 2},
{"id": 3, "owner_id": 2},
])
account_owner = Person.filter_by(id=account_data.owner_id)
m.define(Account.new(id=account_data.id, owner=account_owner))
transaction_data = m.data([
{"id": 101, "payer_id": 1, "payee_id": 2, "amount": 100.0},
{"id": 102, "payer_id": 2, "payee_id": 1, "amount": 200.0},
{"id": 103, "payer_id": 2, "payee_id": 1, "amount": 150.0},
])
txn_payer = Account.filter_by(id=transaction_data.payer_id)
txn_payee = Account.filter_by(id=transaction_data.payee_id)
m.define(Transaction.new(id=transaction_data.id, payer=txn_payer, payee=txn_payee))
# Create an directed, weighted graph.
graph = Graph(m, directed=True, weighted=False)
# Define the edges of the graph.
m.define(
graph.Edge.new(src=Transaction.payer, dst=Transaction.payee),
graph.Edge.new(src=Account.owner, dst=Account),
)
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • Graph(m, directed=True, weighted=False) creates a directed, unweighted graph.
  • A directed graph makes sense here because payments have a clear direction from payer to payee.
  • graph.Edge.new(src=Transaction.payer, dst=Transaction.payee) derives edges from the Transaction concept, connecting payer accounts to payee accounts.

Use a weighted graph when edge magnitude should influence results, like payments with amount as edge weight:

from relationalai.semantics import Integer, Model, Number
from relationalai.semantics.reasoners.graph import Graph
from relationalai.semantics.std import floats
m = Model("WeightedGraph")
# Declare the model schema.
Person = m.Concept("Person", identify_by={"id": Integer})
Account = m.Concept("Account", identify_by={"id": Integer})
Account.owner = m.Property(f"{Account} owned by {Person}")
Transaction = m.Concept("Transaction", identify_by={"id": Integer})
Transaction.payer = m.Property(f"{Transaction} received from {Account:payer}")
Transaction.payee = m.Property(f"{Transaction} payed to {Account:payee}")
Transaction.amount = m.Property(f"{Transaction} has amount {Number:amount}")
# Define base facts.
22 collapsed lines
person_data = m.data([
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
])
m.define(Person.new(person_data.to_schema()))
account_data = m.data([
{"id": 1, "owner_id": 1},
{"id": 2, "owner_id": 2},
{"id": 3, "owner_id": 2},
])
account_owner = Person.filter_by(id=account_data.owner_id)
m.define(Account.new(id=account_data.id, owner=account_owner))
transaction_data = m.data([
{"id": 101, "payer_id": 1, "payee_id": 2, "amount": 100.0},
{"id": 102, "payer_id": 2, "payee_id": 1, "amount": 200.0},
{"id": 103, "payer_id": 2, "payee_id": 3, "amount": 150.0},
])
txn_payer = Account.filter_by(id=transaction_data.payer_id)
txn_payee = Account.filter_by(id=transaction_data.payee_id)
m.define(Transaction.new(id=transaction_data.id, payer=txn_payer, payee=txn_payee, amount=transaction_data.amount))
# Create a directed, weighted graph.
graph = Graph(m, directed=True, weighted=True)
# Define the edges of the graph.
m.define(
graph.Edge.new(
src=Transaction.payer,
dst=Transaction.payee,
weight=floats.float(Transaction.amount) # Weights must be floats
),
)
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • Graph(m, directed=True, weighted=True) enables weight-aware algorithms.
  • Each Edge.new(...) call includes a weight.
  • In weighted graphs, every edge must have a weight.
  • Weights must be finite, non-negative floating point numbers.
  • Use floats.float() to convert from other numeric types if needed.
  • Graph algorithms assume weights are non-negative and finite.

To include every entity instance of a concept as a node, pass the Concept object to the Graph constructor’s node_concept argument. Then define edges between those nodes as usual.

In the following example, every Account is a node, but you only create edges for transaction with a high enough value:

from relationalai.semantics import Float, Integer, Model
from relationalai.semantics.reasoners.graph import Graph
m = Model("AccountsAsNodes")
# Declare the model schema.
Account = m.Concept("Account", identify_by={"id": Integer})
Transaction = m.Concept("Transaction", identify_by={"id": Integer})
Transaction.payer = m.Property(f"{Transaction} payer is {Account:payer}")
Transaction.payee = m.Property(f"{Transaction} payee is {Account:payee}")
Transaction.amount = m.Property(f"{Transaction} amount is {Float:amount}")
# Define base facts
14 collapsed lines
account_data = m.data([
{"id": 1},
{"id": 2},
{"id": 3},
])
m.define(Account.new(account_data.to_schema()))
transaction_data = m.data([
{"id": 101, "payer_id": 1, "payee_id": 2, "amount": 50.0},
{"id": 102, "payer_id": 2, "payee_id": 1, "amount": 200.0},
])
txn_payer = Account.filter_by(id=transaction_data.payer_id)
txn_payee = Account.filter_by(id=transaction_data.payee_id)
m.define(Transaction.new(id=transaction_data.id, payer=txn_payer, payee=txn_payee, amount=transaction_data.amount))
# Created a directed graph with nodes from the Account concept.
graph = Graph(m, directed=True, weighted=False, node_concept=Account)
# Define edges for transactions with amount >= 100.0.
(
m.define(graph.Edge.new(src=Transaction.payer, dst=Transaction.payee))
.where(Transaction.amount >= 100.0)
)
assert graph.Node is Account
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • node_concept=Account makes every Account instance a node in the graph.
  • .where(Transaction.amount >= 100.0) filters which Transaction instances become edges.
  • Account entities with no high value transactions still appear in the graph as isolated nodes.
  • You can still add nodes to the graph that are not in the node_concept by defining edges that connect to them or by explicitly defining them with graph.Node.new(...).

Use this approach when your model already represents each interaction as its own entity (for example, each Transaction is an entity). This is especially useful when edges have their own properties (like amount) and you want those properties available for weighting.

To define edges from an existing concept, set edge_concept=<YourEdgeConcept> when you construct the Graph. You also provide relationships that map each edge entity to its endpoints.

In the following example, every Account is a node, and every Transaction is a distinct directed edge whose weight is the transaction amount:

from relationalai.semantics import Float, Integer, Model
from relationalai.semantics.reasoners.graph import Graph
m = Model("TransactionsAsEdges")
# Declare model schema.
Account = m.Concept("Account", identify_by={"id": Integer})
Transaction = m.Concept("Transaction", identify_by={"id": Integer})
Transaction.payer = m.Relationship(f"{Transaction} payer is {Account}")
Transaction.payee = m.Relationship(f"{Transaction} payee is {Account}")
Transaction.amount = m.Property(f"{Transaction} amount is {Float:amount}")
# Define base facts.
14 collapsed lines
account_data = m.data([
{"id": 1},
{"id": 2},
{"id": 3},
])
m.define(Account.new(account_data.to_schema()))
transaction_data = m.data([
{"id": 101, "payer_id": 1, "payee_id": 2, "amount": 50.0},
{"id": 102, "payer_id": 2, "payee_id": 1, "amount": 200.0},
])
txn_payer = Account.filter_by(id=transaction_data.payer_id)
txn_payee = Account.filter_by(id=transaction_data.payee_id)
m.define(Transaction.new(id=transaction_data.id, payer=txn_payer, payee=txn_payee, amount=transaction_data.amount))
# Create a directed, weighted graph with edges from the `Transaction` concept.
graph = Graph(
m,
directed=True,
weighted=True,
node_concept=Account,
edge_concept=Transaction,
edge_src_relationship=Transaction.payer,
edge_dst_relationship=Transaction.payee,
edge_weight_relationship=Transaction.amount,
)
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • edge_concept=Transaction makes every Transaction instance a distinct graph edge.
  • edge_src_relationship and edge_dst_relationship map each transaction edge to its src and dst properties.
  • edge_weight_relationship=Transaction.amount uses the transaction amount as the edge weight.
  • You must pass all three of edge_concept, edge_src_relationship, and edge_dst_relationship together.
  • edge_weight_relationship is also required when the graph is weighted.
  • With edge_concept, you do not need to define edges separately with graph.Edge.new(...) because the graph automatically treats every instance of that concept as an edge.
  • However, you can still define additional edges with graph.Edge.new(...) if you want to include additional connections that are not represented by the edge_concept.
  • You must pass node_concept when you use edge_concept.

You can use Node.new() to explicitly define nodes in a graph that do not come from a concept in your model. This pattern is not common but could be useful for adding special nodes that represent entities outside of your model.

In the following example, three nodes are defined explicitly with Node.new() and connected by two edges:

from relationalai.semantics import Float, Integer, Model, String
from relationalai.semantics.reasoners.graph import Graph
m = Model("ExplicitNodesAndEdges")
# Create an undirected, unweighted graph.
graph = Graph(m, directed=False, weighted=False)
Node, Edge = graph.Node, graph.Edge
m.define(
# Define nodes for the graph
n1 := Node.new(id=Integer(1)),
n2 := Node.new(id=Integer(2)),
n3 := Node.new(id=Integer(3)),
# Define edges for the graph
Edge.new(src=n1, dst=n2),
Edge.new(src=n2, dst=n3),
)
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • Node is a concept, so Node.new() creates instances of type Node.
  • Since Node has no identifying properties, every argument you pass to Node.new() is used to create an identity for that node.

A multi-edge occurs when two distinct edges share the same src and dst nodes but have different identities. There are two main ways this can happen:

  • In a directed graph, multiple edges between the same src and dst nodes are created with additional properties that differentiate them. This is most common in weighted graph where there are multiple interactions between the same entities with different weights, like multiple transactions between the same payer and payee with different amounts:

    graph = m.Graph(directed=True, weighted=True)
    m.define(
    Edge.new(src=n1, dst=n2, weight=10.0)
    Edge.new(src=n1, dst=n2, weight=2.5)
    )
  • In an undirected graph, multiple edges between the same two nodes can occur when edges are defined for both directions between the same pair of nodes:

    graph = m.Graph(directed=False, weighted=False)
    m.define(
    Edge.new(src=n1, dst=n2),
    Edge.new(src=n2, dst=n1),
    )

Because PyRel graphs are simple graphs, multi-edges are not allowed and will cause errors when you try to materialize graph outputs. However, if your data has multi-edges that are semantically meaningful, such as multiple transactions between the same payer and payee, you can choose to collapse them into a single edge.

To collapse multi-edges, use the aggregator argument when you construct the Graph object:

from relationalai.semantics import Float, Integer, Model
from relationalai.semantics.reasoners.graph import Graph
m = Model("MultiEdgeGraph")
# Declare the model schema.
Account = m.Concept("Account", identify_by={"id": Integer})
Transaction = m.Concept("Transaction", identify_by={"id": Integer})
Transaction.payer = m.Property(f"{Transaction} payed to {Account:payee}")
Transaction.payee = m.Property(f"{Transaction} received from {Account:payer}")
Transaction.amount = m.Property(f"{Transaction} has amount {Float:amount}")
# Define base facts.
15 collapsed lines
account_data = m.data([
{"id": 1},
{"id": 2},
{"id": 3},
])
m.define(Account.new(account_data.to_schema()))
transaction_data = m.data([
{"id": 101, "payer_id": 1, "payee_id": 2, "amount": 100.0},
{"id": 102, "payer_id": 2, "payee_id": 1, "amount": 200.0},
{"id": 103, "payer_id": 2, "payee_id": 1, "amount": 150.0},
])
txn_payer = Account.filter_by(id=transaction_data.payer_id)
txn_payee = Account.ref().filter_by(id=transaction_data.payee_id)
m.define(Transaction.new(id=transaction_data.id, payer=txn_payer, payee=txn_payee, amount=transaction_data.amount))
# Create a directed, weighted graph that collapses multi-edges by summing weights.
graph = Graph(m, directed=True, weighted=True, aggregator="sum")
# Define the edges of the graph.
m.define(graph.Edge.new(src=Transaction.payer, dst=Transaction.payee, weight=Transaction.amount))
# Validate the graph with basic counts.
graph.num_nodes().inspect()
graph.num_edges().inspect()
  • aggregator="sum" collapses multi-edges by summing their weights.
  • Currently, "sum" is the only supported aggregator.
  • You can use aggregator="sum" to collapse multi-edges in an unweighted graph as well.

Use this table to diagnose common symptoms with graphs:

SymptomLikely causeWhat to do
num_edges is 0No edges were defined.Make sure you call define() for Edge.new() declarations. If your edge definition is conditional, ensure the conditions are not filtering out all edges. If you used edge_concept, make sure instances of the concept have been defined.
num_nodes is 0No nodes were defined.If nodes were implicitly defined by edges, ensure your edge definitions do not filter out all nodes. If you used node_concept, make sure instances of the concept have been defined.
Weights are not showing up in edges.You created an unweighted graph.Make sure you set weighted=True when you construct the Graph object.