Drawing a knowledge graph

Use IDs, arrows, labels, and tables before reading a layout

information visualization
knowledge graph visualization
networks
Learn how to draw a small knowledge graph in R with ggraph while keeping identifiers, edge labels, layout limits, and text alternatives visible.

Chloe has a tidy table of Riverton facts, but the table is hard to scan when she wants to follow a path. Which organisation offers the credential? Which deadline values did the knowledge base keep for review? Which place label might cause trouble if IDs were ignored?

Knowledge graph visualization draws entities as nodes and typed relations as edges. In this lesson, the edges are directed and labelled because the relation itself is part of the data.

A drawing can help Chloe see a path. It can also mislead her if she treats the layout as a measurement. The table of edges remains the source of the graph.

Note

The Riverton entities and facts in this lesson are fictional teaching records rebuilt from the earlier knowledge base example.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • key graph nodes by entity ID instead of display label;
  • show how label-keyed data wrongly merges two Riverton places;
  • draw directed, labelled edges with ggraph, the ggplot2 extension for networks;
  • explain why layout coordinates can change and are not data;
  • give a captioned edge table as the text alternative; and
  • compare a co-word network with a typed knowledge graph.

Rebuild the graph facts by ID

The graph starts with the populated fact edges named in the knowledge-base lesson. The two Riverton places have different IDs. That difference is the first thing the drawing must preserve.

library(dplyr)
library(tibble)
library(tidyr)
library(purrr)
library(stringr)
library(igraph)
library(tidygraph)
library(ggplot2)
library(ggraph)
library(tidytext)

source("R/inaugural-corpus.R")
source("R/lesson-figures.R")

nodes <- tribble(
  ~node_id, ~label, ~node_type,
  "ORG-0002", "Riverton Skills Centre", "organisation",
  "ORG-0003", "Marrow County Transit", "organisation",
  "LOC-0001", "Riverton", "place",
  "LOC-0002", "Riverton", "place",
  "LOC-0003", "Bellhaven", "place",
  "CRD-0002", "Data Support Certificate", "credential",
  "VALUE-October 15", "October 15", "date literal",
  "VALUE-November 1", "November 1", "date literal"
)

edges <- tribble(
  ~from, ~to, ~relation, ~document_ids,
  "ORG-0002", "CRD-0002", "offers", "A001; A002",
  "ORG-0002", "VALUE-October 15", "application_deadline", "A003",
  "ORG-0002", "VALUE-November 1", "application_deadline", "A004"
)
Every edge drawn in the Riverton knowledge graph
From Relation To Supporting documents
Riverton Skills Centre (ORG-0002) offers Data Support Certificate (CRD-0002) A001; A002
Riverton Skills Centre (ORG-0002) application deadline October 15 (VALUE-October 15) A003
Riverton Skills Centre (ORG-0002) application deadline November 1 (VALUE-November 1) A004

The table is the long description of the graph. It lists every drawn edge with IDs and labels, so each relation can be checked without seeing the picture. Both deadline values are kept because the knowledge base flagged them as a dated conflict rather than choosing one automatically. No year appears in these labels because the announcements say only October 15 and November 1.

Show the label-keyed mistake

If the graph used labels as node keys, both Riverton records would collapse into one node. That would merge two different places, so any later fact about one Riverton would appear to belong to the other.

id_graph <- tbl_graph(
  nodes = nodes |> rename(name = node_id),
  edges = edges,
  directed = TRUE,
  node_key = "name"
)

label_edges <- edges |>
  left_join(nodes |> select(from = node_id, from_label = label), by = "from") |>
  left_join(nodes |> select(to = node_id, to_label = label), by = "to") |>
  transmute(from = from_label, to = to_label, relation)

label_keyed_graph <- graph_from_data_frame(
  d = label_edges,
  directed = TRUE,
  vertices = nodes |> distinct(name = label)
)

merge_check <- tibble(
  key_used = c("entity ID", "display label"),
  vertices = c(vcount(id_graph), vcount(label_keyed_graph)),
  riverton_vertices = c(
    sum(V(id_graph)$label == "Riverton"),
    sum(V(label_keyed_graph)$name == "Riverton")
  )
)
Using labels as keys wrongly merges the two Riverton places
Key used Vertices Vertices displayed as Riverton
entity ID 8 2
display label 7 1

tbl_graph() from tidygraph builds the graph from the two tables, with the entity ID as the node key. The ID-keyed graph has eight vertices. The label-keyed graph has seven because the two places named Riverton have been merged.

name_clash <- nodes |>
  summarise(
    ids_with_label = n(),
    entity_ids = str_c(node_id, collapse = "; "),
    entity_types = str_c(node_type, collapse = "; "),
    .by = label
  ) |>
  filter(ids_with_label > 1L)

unconnected_nodes <- nodes |>
  filter(!node_id %in% c(edges$from, edges$to))
The name clash is a label problem, not a factual graph edge
Shared label IDs with label Entity IDs Entity types
Riverton 2 LOC-0001; LOC-0002 place; place
Nodes drawn without a populated-fact edge
Node ID Label Type
ORG-0003 Marrow County Transit organisation
LOC-0001 Riverton place
LOC-0002 Riverton place
LOC-0003 Bellhaven place

The figure draws 4 nodes that have no populated fact yet. It places them in its bottom row, and the table above names them.

Place nodes by hand and draw arrows

ggraph draws graphs with the same grammar as ggplot2: a layout gives each node a position, geom_edge_*() layers draw the edges, and geom_node_*() layers draw the nodes. Here the layout is written by hand, so the drawing stays the same on every render while Chloe reads the arrows and labels.

The bottom row holds the nodes with no populated fact. Apart from that row, up, down, left, right, rotation, and mirror image carry no data. Count links in the table if you need a number. Viewers tend to read a centrally drawn node as more important, so the drawing should not be used as an importance score.

hand_layout <- tribble(
  ~name, ~x, ~y,
  "ORG-0002", 0.0, 0.0,
  "CRD-0002", -3.2, 1.9,
  "VALUE-October 15", 3.2, 1.9,
  "VALUE-November 1", 3.2, -1.7,
  "LOC-0001", -3.6, -3.6,
  "LOC-0002", -1.2, -3.6,
  "ORG-0003", 1.2, -3.6,
  "LOC-0003", 3.6, -3.6
)

node_type_fills <- c(
  organisation = "#D6E6F4",
  credential = "#D5EFE6",
  "date literal" = "#FBE3CF",
  place = "#E6E9ED"
)

riverton_graph <- id_graph |>
  activate(nodes) |>
  left_join(hand_layout, by = "name") |>
  mutate(display_label = str_c(label, "\n", name)) |>
  activate(edges) |>
  mutate(relation_label = str_replace_all(relation, "_", " "))

graph_plot <- ggraph(
  riverton_graph,
  layout = "manual",
  x = x,
  y = y
) +
  geom_edge_link(
    aes(
      label = relation_label,
      start_cap = label_rect(node1.display_label, fontsize = 11, fontfamily = lesson_font),
      end_cap = label_rect(node2.display_label, fontsize = 11, fontfamily = lesson_font)
    ),
    arrow = arrow(type = "closed", length = unit(3, "mm")),
    angle_calc = "along",
    label_dodge = unit(3.2, "mm"),
    label_size = 3.4,
    family = lesson_font,
    edge_colour = lesson_colours[["body"]],
    edge_width = 0.5
  ) +
  geom_node_label(
    aes(label = display_label, fill = node_type),
    family = lesson_font,
    size = 3.8,
    lineheight = 1.05,
    colour = lesson_colours[["ink"]],
    label.padding = unit(0.35, "lines"),
    label.r = unit(0.2, "lines"),
    key_glyph = "rect"
  ) +
  scale_fill_manual(values = node_type_fills, name = "Node type") +
  coord_cartesian(clip = "off") +
  scale_x_continuous(expand = expansion(add = 1.2)) +
  scale_y_continuous(expand = expansion(add = 0.6)) +
  labs(
    title = "Facts connect IDs, so the two places named Riverton stay apart",
    subtitle = str_c(
      "Arrows point from subject to object and carry the relation. ",
      "The bottom row holds nodes with no populated fact."
    )
  ) +
  theme_lesson(grid = "none") +
  theme(
    axis.text = element_blank(),
    axis.title = element_blank(),
    legend.key.size = unit(0.9, "lines")
  )

graph_plot
A node-link diagram of 8 nodes, each labelled with a name and an ID, and 3 directed relation edges. Riverton Skills Centre (ORG-0002) offers the Data Support Certificate (CRD-0002) and has two application deadlines, October 15 and November 1. A bottom row holds four nodes with no populated fact, including the two places labelled Riverton, which stay separate because they have different IDs. The exact edges are listed in the edge table above.
Figure 1: ID-keyed Riverton knowledge graph with directed, labelled edges; the bottom row holds nodes with no populated fact.

The relation labels are text, not colour. The arrowheads show direction, and the caption and table make the same information available outside the image. The fill colours group node types, and the ID prefix inside every label says the same thing in words.

Check that a layout seed changes only the picture

Force-directed layouts are useful for many graphs, but their coordinates depend on random starting positions. A layout cannot change the graph’s edges or degrees; it only assigns positions. The code below runs two seeded Fruchterman-Reingold layouts and checks only whether their coordinates are identical, without printing them.

layout_for_seed <- function(graph, seed) {
  set.seed(seed)
  layout_with_fr(graph, weights = NA)
}

coordinates_identical <- identical(
  layout_for_seed(id_graph, 17L),
  layout_for_seed(id_graph, 29L)
)

coordinates_identical
[1] FALSE

FALSE means the two seeds put the same eight nodes in different places. Use the hand-placed figure to follow the labelled arrows; the seeded check is here only to show why coordinates should not be treated as facts.

Build a co-word network for comparison

A co-word network is a different graph. Its nodes are words. Its edges are undirected and based on how often two words appear in the same unit. Here the unit is an inaugural paragraph. The word rule keeps alphabetic tokens of four or more letters, lowercases them in English, removes tidytext stop words, and uses paragraph presence rather than raw repeated counts.

paragraphs <- inaugural_paragraphs()

paragraph_words <- paragraphs |>
  transmute(paragraph_id, paragraph) |>
  unnest_tokens(word, paragraph, token = "regex", pattern = "[^A-Za-z]+") |>
  mutate(word = str_to_lower(word, locale = "en")) |>
  filter(nchar(word) >= 4L) |>
  anti_join(stop_words, by = "word") |>
  distinct(paragraph_id, word)

top_words <- paragraph_words |>
  count(word, name = "paragraphs_with_word", sort = TRUE) |>
  slice_head(n = 10)

top_word_presence <- paragraph_words |>
  semi_join(top_words, by = "word")

word_pairs <- top_word_presence |>
  inner_join(top_word_presence, by = "paragraph_id", relationship = "many-to-many") |>
  filter(word.x < word.y) |>
  count(word.x, word.y, name = "observed") |>
  left_join(top_words, by = c("word.x" = "word")) |>
  rename(first_paragraphs = paragraphs_with_word) |>
  left_join(top_words, by = c("word.y" = "word")) |>
  rename(second_paragraphs = paragraphs_with_word) |>
  mutate(
    total_paragraphs = n_distinct(paragraphs$paragraph_id),
    expected = first_paragraphs * second_paragraphs / total_paragraphs,
    observed_expected_ratio = observed / expected
  )

ratio_threshold <- 1.80
coword_edges <- word_pairs |>
  filter(observed_expected_ratio >= ratio_threshold) |>
  arrange(desc(observed_expected_ratio), word.x, word.y)

undrawn_words <- setdiff(top_words$word, c(coword_edges$word.x, coword_edges$word.y))
Top ten content words after the paragraph-level word rule
Word Paragraphs with word
people 409
government 358
nation 268
country 259
world 251
citizens 196
time 189
peace 172
power 169
america 167
Co-word edges kept by the observed-versus-expected threshold
First word Second word Observed paragraphs First word paragraphs Second word paragraphs Total paragraphs Expected under independence Observed / expected
peace world 68 172 251 1377 31.4 2.17
america world 58 167 251 1377 30.4 1.91
citizens country 70 196 259 1377 36.9 1.90

Raw co-occurrence among these ten frequent words is a complete graph: all 45 pairs appear in at least one paragraph. The threshold is the author’s choice for this lesson. It keeps 3 pairs and drops 42 by requiring the observed count to be at least 1.8 times the count expected if the two words were independent, count(word A) * count(word B) / paragraphs. The top-ten words left out of the drawing are people, government, nation, time, power.

Draw the co-word graph

The co-word graph uses the same ggraph grammar, but its edges are not typed facts. They are paragraph-level co-occurrences that passed the threshold, so each edge is labelled with its observed-to-expected ratio instead of a relation.

# Listing the words edge by edge puts linked words next to each other on the
# circle, so no two edges cross.
coword_nodes <- tibble(
  name = unique(as.vector(rbind(coword_edges$word.x, coword_edges$word.y)))
)

coword_graph <- tbl_graph(
  nodes = coword_nodes,
  edges = coword_edges |>
    transmute(
      from = word.x,
      to = word.y,
      ratio_label = sprintf("%.1f×", observed_expected_ratio)
    ),
  directed = FALSE
)

coword_plot <- ggraph(coword_graph, layout = "circle") +
  geom_edge_link(
    aes(
      label = ratio_label,
      start_cap = label_rect(node1.name, fontsize = 12, fontfamily = lesson_font),
      end_cap = label_rect(node2.name, fontsize = 12, fontfamily = lesson_font)
    ),
    angle_calc = "along",
    label_dodge = unit(3, "mm"),
    label_size = 3.3,
    family = lesson_font,
    edge_colour = lesson_colours[["body"]],
    edge_width = 0.6
  ) +
  geom_node_label(
    aes(label = name),
    family = lesson_font,
    size = 4.2,
    fill = lesson_colours[["paper"]],
    colour = lesson_colours[["ink"]],
    label.padding = unit(0.3, "lines")
  ) +
  coord_cartesian(clip = "off") +
  scale_x_continuous(expand = expansion(mult = 0.15)) +
  scale_y_continuous(expand = expansion(mult = 0.15)) +
  labs(
    title = str_c(
      "Only ", nrow(coword_edges), " of ", nrow(word_pairs),
      " frequent word pairs pass the threshold"
    ),
    subtitle = "Undirected edges between words that share paragraphs at least 1.8 times as often as expected"
  ) +
  theme_lesson(grid = "none") +
  theme(axis.text = element_blank(), axis.title = element_blank())

coword_plot
An undirected co-word diagram whose nodes are content words and whose edges are thresholded paragraph co-occurrences, each labelled with its observed-to-expected ratio. The kept edges and counts are listed in the table 'Co-word edges kept by the observed-versus-expected threshold'.
Figure 2: Co-word network for the three top-word pairs that passed the observed-versus-expected threshold.

Unlike the Riverton knowledge graph, this co-word network has no entity IDs, no relation types, and no direction. It can show which word pairs survived a preprocessing and threshold decision. It cannot say that the words mean the same thing or that one caused the other to appear.

What to remember

  • Use stable IDs as node keys; labels can collide.
  • The two Riverton places merge if labels are treated as IDs.
  • A force-directed layout is a computed drawing, not a measurement.
  • Arrowheads and relation labels carry the graph meaning.
  • The edge-list table is part of the graph’s text alternative.
  • Co-word edges are thresholded co-occurrences, not typed knowledge-base facts.

Start with the edge table, then use the drawing only as a guide to the paths it lists.

Sources