Comparing knowledge graphs
You have two assemblies of a disease graph — last quarter’s and this one — and a ranking computed from each. This is how you load them, how Ligase works out which evidence sources went into each, and how it stops you comparing the two as though the difference between them were biology.
Load a graph
A knowledge graph is a KnowledgeGraph: one JSON object with a nodes array
and an edges array.
import ligase as lg
graph = lg.knowledge_graph("graphs/2026-09.json", organism="Homo sapiens")The file behind it:
{
"nodes": [
{
"id": "ENSG00000141510",
"type": "gene",
"ids": ["HGNC:11998", "P04637"],
"sources": ["open-targets"]
},
{ "id": "EFO_0000305", "type": "disease", "sources": ["open-targets"] }
],
"edges": [
{
"source": "ENSG00000141510",
"target": "EFO_0000305",
"type": "associated_with",
"sources": ["open-targets", "disgenet"],
"score": 0.82
}
]
}A node needs a string id — that is what an edge points at. An edge needs a
string source, target and type. Beyond that:
sources, on a node, on an edge, or at the top level, is the per-edge provenance thesource_setdimension is read from.ids, on a node, is the entity’s other names.- Everything else on either is carried without being read. A
score, apmid, a confidence band, your own annotations: what a graph says about its own nodes is your business, exactly as a gene list’s attribute columns are.
A file that is not this shape does not load. A node with no id cannot be an
edge endpoint, so a file holding one is not the thing it claims to be, and the
loader says so rather than reading what it can.
What the engine read off it
>>> graph.dimension("source_set").value
'open-targets and disgenet'
>>> graph.dimension("source_set").source
'from the sources named on its nodes and edges: graphs/2026-09.json'That is the union of every sources list in the file, and it is the dimension
this kind exists for. Nothing in a node or an edge can state it — it is a fact
about the whole graph — and nothing else would have computed it.
The identifier space comes off the node ids where they all agree:
>>> graph.dimension("identifier_namespace").value
'Ensembl gene'A graph of genes and diseases will not agree, because an EFO term is no Ensembl gene. That is ordinary for a graph rather than a fault, so the dimension stays unknown with a reason that says so, and you declare it if a downstream step needs it.
Everything else a graph carries, you declare:
graph = lg.knowledge_graph(
"graphs/2026-09.json",
organism=["Homo sapiens", "Mus musculus"],
subject="breast carcinoma",
release="Open Targets 24.09",
)organism takes a list because this is the kind where several organisms is
the normal case: a disease graph joining human genes to evidence from model
organisms is about both, and a graph declared as only the one the analysis is
about is a declaration that is true of the analysis and false of the data.
The three refusals
Different evidence sources
This is the one the kind exists for. Two graphs holding the same nodes and the same edges, one of which also folded in DisGeNET:
march = lg.knowledge_graph("graphs/2026-03.json", organism="Homo sapiens")
september = lg.knowledge_graph("graphs/2026-09.json", organism="Homo sapiens")
lg.check_step("compare", {"ours": march, "theirs": september})Both sets are printed in full, then the member that makes them differ is named on a line of its own. For two graphs with a dozen sources each, that line is the only part worth reading.
There is no flag that makes this pass, because the two graphs really are different graphs. Reassemble both from one source list, or stop comparing them — and say in the write-up that they are not comparable, which is the honest version of what you would otherwise have published.
Sources that differ only in case
ours = lg.knowledge_graph("graphs/upper.json")
theirs = lg.knowledge_graph("graphs/lower.json")
lg.check_step("compare", {"ours": ours, "theirs": theirs})s3://evidence/A.json and s3://evidence/a.json are two objects by
specification, so they are two sources, and each side holds one the other
lacks. An organism would have folded them — HIV-1 and hiv-1 are one
organism by convention — and a source does not. The reasoning is
D36:
folding case can drop a member silently, and not folding it can only produce a
refusal you can read.
A graph scored against a list in another space
Scoring a graph’s neighbourhood against a curated gene list is the commonest reason to join those two kinds at all, so the namespace is demanded of the pair:
truth = lg.entity_set(
"truth/known_drivers.tsv",
organism="Homo sapiens",
identifier_namespace="HGNC symbol",
)
lg.check_step("score", {"kg": graph, "truth": truth})The way across is a map_identifiers step on the list, which declares the
space it crossed to and names every identifier that did not cross — see
crossing identifier spaces. There is no
conversion for a graph: a graph’s identifiers are node ids and edge
endpoints, and rewriting one without the other would produce a graph whose
edges point at nothing.
In a pipeline
A graph is an input like any other, and a step over one inherits every axis it claims:
@lg.step(tool="python3")
def prune(kg: lg.KnowledgeGraph, floor: float = 0.5) -> lg.KnowledgeGraph:
"""Drop the edges scoring below the floor."""
return lg.sh("python3 scripts/prune.py --floor {floor} {kg} > {out}")
pruned = prune(graph, floor=0.7)
lg.Pipeline(pruned).check()pruned carries September’s source_set, so three steps later — on a file
that no longer mentions a source anywhere — anything that tries to score it
against March’s assembly is refused the same way. That is what the dimension
buys you over reading the two files by eye once.
Fetching a graph instead
A graph is often the answer to a live query rather than a file on disk. Both halves of where the data came from take it:
graph = lg.query(
"https://api.platform.opentargets.org/api/v4/graphql?query=...",
kind=lg.KnowledgeGraph,
organism="Homo sapiens",
release="Open Targets 24.09",
)The answer is canonicalised — keys sorted at every depth, the service’s own
generatedAt and requestId dropped from the top level — and the body goes
into the content-addressed store, so the next run replays it with no network
at all. The sources come off the answer the service gave, exactly as they come
off a file. See recording a live query.
Next
Assembled data is why the sources are a dimension at all. My target ranking changed and I cannot find the analysis change is the failure written up in full, and the dimension catalogue is where the axis sits among the others.