home

Graph Neural Networks

Creator: Petar Veličković

A node learns from its neighborhood. These views connect the local operation, the growth of its receptive field, and two ways to construct the information being combined.

Sources: Neural Message Passing for Quantum Chemistry and Graph Attention Networks.

Original illustrations: Graph Convolution, Message Passing Neural Network, Graph Attention Layer.


Graph Neural Networks

Download

PNG PDF SVG

Code

graph-neural-networks.typ (194 lines)
#import "@preview/cetz:0.5.3": canvas, draw
#import draw: circle, content, line
#let label-size = 12pt
#let paragraph-size = 14pt
#let heading-size = 16pt

#let card_body(title, body, caption) = block(
  width: 100%,
  inset: 12pt,
  radius: 8pt,
  fill: rgb("#cdd3da"),
  breakable: false,
)[
  #text(size: heading-size, weight: "bold", title)
  #v(8pt)
  // Measure unconstrained artwork before scaling, including content wider than its card.
  #layout(size => {
    let artwork = text(size: label-size, body)
    std.scale(size.width / measure(artwork).width * 100%, reflow: true, artwork)
  })
  #v(7pt)
  #text(size: paragraph-size, caption)
]

#let card-grid(columns: 2, ..cards) = layout(size => {
  let rows = cards
    .pos()
    .chunks(columns)
    .map(row => {
      let ratios = row.map(card => {
        let bounds = measure(text(size: label-size, card.at(1)))
        bounds.width / bounds.height
      })
      let available = size.width - 12pt * (row.len() - 1) - 24pt * row.len()
      grid(
        columns: ratios.map(ratio => 24pt + available * ratio / ratios.sum()),
        gutter: 12pt,
        ..row.map(args => card_body(..args)),
      )
    })
  stack(dir: ttb, spacing: 12pt, ..rows)
})

#let takeaway(body) = block(
  width: 100%,
  inset: 12pt,
  radius: 6pt,
  fill: rgb("#c6d8d2"),
  breakable: false,
  text(size: paragraph-size, body),
)

#set page(width: 780pt, height: auto, margin: 22pt, fill: none)
#set text(font: "Avenir Next", size: paragraph-size, fill: rgb("#19324f"))
#set par(leading: 0.55em)

#let neighborhood(mode) = canvas(
  length: if mode == "aggregate" { 1.08cm } else { 1.52cm },
  {
    let teal = rgb("#008580")
    circle((0, 0), radius: .42, fill: rgb("#d3ede5"), stroke: teal + 1pt, name: "center")
    content("center", $h_v$)
    for (idx, pos) in ((-2, 1.3), (-2, -1.3), (0, 2), (0, -2)).enumerate() {
      circle(
        pos,
        radius: .35,
        fill: rgb("#d6e9f8"),
        stroke: rgb("#537da0") + .7pt,
        name: "neighbor",
      )
      content(pos, $h_#(idx + 1)$)
      let width = if mode == "attention" { (.5, 1, 2, 3).at(idx) * 1pt } else { 1pt }
      line("neighbor", "center", stroke: teal + width, mark: (end: "stealth", scale: .5))
    }
    line("center", (3, 0), stroke: teal + 1.5pt, mark: (end: "stealth"))
    content((3.6, 0), $h′_v$, frame: "circle", padding: 7pt, fill: rgb("#fbe4d4"), stroke: none)
    let (arrow-label, note, note-x) = (
      aggregate: ([aggregate + update], [sum or mean ignores neighbor ordering], -.4),
      message: ([message + update], [message $m_(u v)=M(h_u,h_v,e_(u v))$], -.4),
      attention: ([weighted sum], [thicker arrow = larger learned weight], -.8),
    ).at(mode)
    content((2.2, .65), arrow-label)
    content((note-x, -2.8), note)
  },
)

// Receptive field of node A: the input graph and its two-hop computation tree.
#let receptive-field = canvas(length: .89cm, {
  let arrow-style = (
    mark: (end: "stealth", fill: black, scale: 0.5, offset: 2pt),
    stroke: 0.5pt,
  )
  // Light tints keep the node letters legible after the site's dark-mode inversion.
  let colors = (
    A: rgb("#ffd700"),
    B: rgb("#ffa8a8"),
    C: rgb("#90ee90"),
    D: rgb("#a3c8ff"),
    E: rgb("#c8b6f2"),
    F: rgb("#ffa8d4"),
  )
  let draw-node(pos, label, name) = {
    circle(pos, radius: 0.3, fill: colors.at(label), stroke: 0.5pt, name: name)
    content(pos, label)
  }
  let aggr-box(pos, body, padding, name) = content(
    pos,
    body,
    fill: rgb("ddd"),
    frame: "rect",
    stroke: 0.2pt,
    padding: padding,
    name: name,
  )

  for (pos, label, name) in (
    ((-1.5, 1.2), "A", "target"),
    ((0.5, 2), "B", "b"),
    ((1, 1), "C", "c"),
    ((-2.5, -.7), "D", "d"),
    ((-0.25, -1.25), "E", "e"),
    ((1.5, 0), "F", "f"),
  ) { draw-node(pos, label, name) }

  content((rel: (0, 1.5), to: "target"), "Target Node", name: "target-label")
  line("target-label.south", "target", ..arrow-style)

  for (start, end) in (
    ("target", "b"),
    ("target", "c"),
    ("b", "c"),
    ("target", "d"),
    ("c", "e"),
    ("c", "f"),
    ("e", "f"),
  ) {
    line(start, end, stroke: 0.4pt)
  }

  content((0.25, -1.8), [Input Graph])

  aggr-box((4.5, 0.5), [Aggregation\ for Node A], (3pt, 7pt), "agg-box")
  // Hop 1: A's neighbors, each with its own aggregation box.
  for (dy, label) in ((2, "B"), (0, "C"), (-2, "D")) {
    let name = "a-to-" + lower(label)
    draw-node((rel: (2, dy), to: "agg-box.east"), label, name)
    line(name, "agg-box.east", ..arrow-style)
    aggr-box((rel: (2, 0), to: name), [Aggr(#label)], (2pt, 4pt), "aggr-" + lower(label))
    line("aggr-" + lower(label), name, ..arrow-style)
  }
  content((rel: (0, .7), to: "a-to-b"), "Hop 1")

  // Hop 2: the neighbors of B, C and D.
  for (dy, label, parent, name) in (
    (1, "A", "aggr-b", "b-to-a"),
    (0, "C", "aggr-b", "b-to-c"),
    (1, "A", "aggr-c", "c-to-a"),
    (0.25, "B", "aggr-c", "c-to-b"),
    (-0.5, "E", "aggr-c", "c-to-e"),
    (-1.25, "F", "aggr-c", "c-to-f"),
    (0, "A", "aggr-d", "d-to-a"),
  ) {
    draw-node((rel: (2, dy), to: parent), label, name)
    line(name, parent + ".east", ..arrow-style)
  }
  content((rel: (0, .7), to: "b-to-a"), "Hop 2")
})

A node learns from its neighborhood. These views connect the local operation, the growth of its receptive field, and two ways to construct the information being combined.
#v(14pt)
#card-grid(
  (
    [1  Aggregate the neighbors],
    neighborhood("aggregate"),
    [Graph convolution mixes neighboring node features with shared parameters. A sum or mean is unchanged when the neighbors are reordered; include self-information in the update.],
  ),
  (
    [2  Stack layers to reach farther],
    receptive-field,
    [One layer communicates across one edge; two layers can use two-hop information. The expanded tree shows computation paths, not duplicated physical nodes.],
  ),
  (
    [3  Construct messages],
    neighborhood("message"),
    [A message function $M$ can depend on sender features, receiver features, and edge attributes $e_(u v)$. Aggregate the messages, then update the receiving node.],
  ),
  (
    [4  Learn which neighbors matter],
    neighborhood("attention"),
    [Attention assigns normalized weights to neighbors before aggregation. Multiple heads learn different weightings, then concatenate or average their outputs.],
  ),
)
#v(12pt)
#takeaway[$h_v$ = features at node $v$; $h′_v$ = updated features. *Shared local rules preserve node relabeling symmetry.* A graph-level prediction can pool node features into one permutation-invariant representation.]