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 (257 lines)

#import "@preview/cetz:0.5.2": 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 sites = ((-2, 1.3), (-2, -1.3), (0, 2), (0, -2))
    for (idx, pos) in sites.enumerate() {
      draw.circle(
        pos,
        radius: .35,
        fill: rgb("#d6e9f8"),
        stroke: rgb("#537da0") + .7pt,
        name: "neighbor" + str(idx),
      )
      draw.content(pos, $h_#(idx + 1)$)
    }
    draw.circle(
      (0, 0),
      radius: .42,
      fill: rgb("#d3ede5"),
      stroke: rgb("#008580") + 1pt,
      name: "center",
    )
    draw.content((0, 0), $h_v$)
    for (idx, pos) in sites.enumerate() {
      let width = if mode == "attention" { (.5, 1, 2, 3).at(idx) * 1pt } else { 1pt }
      draw.line("neighbor" + str(idx), "center", stroke: rgb("#008580") + width, mark: (
        end: "stealth",
        scale: .5,
      ))
    }
    draw.line("center", (3, 0), stroke: rgb("#008580") + 1.5pt, mark: (end: "stealth"))
    draw.content(
      (3.6, 0),
      $h′_v$,
      frame: "circle",
      padding: 7pt,
      fill: rgb("#fbe4d4"),
      stroke: none,
    )
    draw.content((2.2, .65), if mode == "attention" { [weighted sum] } else if mode == "message" {
      [message + update]
    } else { [aggregate + update] })
    if mode == "attention" {
      draw.content((-.8, -2.8), [thicker arrow = larger learned weight])
    } else if mode == "message" {
      draw.content((-.4, -2.8), $m_(u v)=M(h_u,h_v,e_(u v))$)
    } else {
      draw.content((-.4, -2.8), [sum or mean ignores neighbor ordering])
    }
  },
)

// === 2  Stack layers to reach farther ===
#let figure-1 = canvas(length: .89cm, {
  let arrow-style = (
    mark: (end: "stealth", fill: black, scale: 0.5, offset: 2pt),
    stroke: 0.5pt,
  )
  let edge-style = (stroke: 0.4pt)
  let node-radius = 0.3
  let graph-sep = 4.5 // separation between input graph and aggregation

  // Node colors - ensure consistency
  let colors = (
    A: rgb("#ffd700"), // Gold
    B: rgb("#ff4d4d"), // Red
    C: rgb("#90ee90"), // Light green
    D: rgb("#4d94ff"), // Blue
    E: rgb("#9370db"), // Purple
    F: rgb("#ff69b4"), // Pink
  )

  let draw-node(pos, label, name) = {
    circle(
      pos,
      radius: node-radius,
      fill: colors.at(label),
      stroke: 0.5pt,
      name: name,
    )
    content(pos, label, anchor: "center")
  }

  // Input Graph (left side)
  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, ..edge-style)
  }

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

  // Main aggregation box
  let box-pos = (graph-sep, 0.5)
  content(
    box-pos,
    [Aggregation\ for Node A],
    name: "agg-box",
    fill: rgb("ddd"),
    frame: "rect",
    stroke: 0.2pt,
    padding: (3pt, 7pt),
  )

  // First layer nodes - renamed to show they're neighbors of A
  let first-layer = (
    (2, 2, "B", "a-to-b"),
    (2, 0, "C", "a-to-c"),
    (2, -2, "D", "a-to-d"),
  )

  // Draw first layer nodes and arrows
  for (dx, dy, label, name) in first-layer {
    draw-node((rel: (dx, dy), to: "agg-box.east"), label, name)
    line(name, "agg-box.east", ..arrow-style)
  }

  content((rel: (0, .7), to: "a-to-b"), "Hop 1")

  // Draw aggregation boxes for each first layer node
  for (_, _, label, node) in first-layer {
    let letter = lower(label)
    content(
      (rel: (2, 0), to: node),
      [Aggr(#label)],
      fill: rgb("ddd"),
      frame: "rect",
      stroke: 0.2pt,
      padding: (2pt, 4pt),
      name: "aggr-" + letter,
    )
    line("aggr-" + letter, node, ..arrow-style)
  }

  // Second layer nodes and connections - renamed to show full path
  let second-layer = (
    // From B-aggregation (B's neighbors)
    ((2, 1), "A", "aggr-b", "b-to-a"),
    ((2, 0), "C", "aggr-b", "b-to-c"),
    // From C-aggregation (C's neighbors)
    ((2, 1), "A", "aggr-c", "c-to-a"),
    ((2, 0.25), "B", "aggr-c", "c-to-b"),
    ((2, -0.5), "E", "aggr-c", "c-to-e"),
    ((2, -1.25), "F", "aggr-c", "c-to-f"),
    // From D-aggregation (D's neighbors)
    ((2, 0), "A", "aggr-d", "d-to-a"),
  )

  for ((dx, dy), label, parent, name) in second-layer {
    draw-node((rel: (dx, 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],
    figure-1,
    [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.]