<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://rizkisalaheddine.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://rizkisalaheddine.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-24T13:20:02+00:00</updated><id>https://rizkisalaheddine.github.io/feed.xml</id><title type="html">blank</title><subtitle>Academic website of Salah-Eddine Rizki, a PhD candidate working on graph representation learning, geometric deep learning, molecular modeling, generative modeling, and explainable AI. </subtitle><entry><title type="html">Message-Passing Graph Neural Networks from Scratch: GCN and GAT in Pure PyTorch</title><link href="https://rizkisalaheddine.github.io/blog/2026/message-passing-gnns-from-scratch/" rel="alternate" type="text/html" title="Message-Passing Graph Neural Networks from Scratch: GCN and GAT in Pure PyTorch"/><published>2026-08-23T22:01:00+00:00</published><updated>2026-08-23T22:01:00+00:00</updated><id>https://rizkisalaheddine.github.io/blog/2026/message-passing-gnns-from-scratch</id><content type="html" xml:base="https://rizkisalaheddine.github.io/blog/2026/message-passing-gnns-from-scratch/"><![CDATA[<h2 id="abstract">Abstract</h2> <p>Graph neural networks operate on observations whose geometry is specified by relations rather than by a regular grid. Their central computation is local: each node constructs messages from its neighbors, aggregates them without depending on neighbor order, and updates its representation. This article develops that computation from first principles and specializes it to two canonical architectures: the Graph Convolutional Network (GCN) and the Graph Attention Network (GAT).</p> <p>The implementations use ordinary PyTorch tensors only. In particular, they do not use PyTorch Geometric, <code class="language-plaintext highlighter-rouge">GCNConv</code>, <code class="language-plaintext highlighter-rouge">GATConv</code>, or a message-passing base class. This is an educational choice: sparse edge indexing, neighborhood normalization, attention coefficients, and scatter aggregation remain explicit. The complete implementation and reproducible experiments are available in the <a href="https://github.com/rizkisalaheddine/message-passing-gnns-from-scratch">companion repository</a>.</p> <h2 id="1-graph-data">1. Graph data</h2> <h3 id="11-definition-and-attributes">1.1 Definition and attributes</h3> <p>A graph is a pair</p> \[G=(V,E),\] <p>where $V=\lbrace 1,\ldots,N\rbrace$ is a set of nodes and $E\subseteq V\times V$ is a set of edges. An edge $(j,i)$ will be written $j\rightarrow i$: node $j$ is its source and node $i$ its destination. An undirected relation $\lbrace i,j\rbrace$ is represented computationally by the two directed edges $i\rightarrow j$ and $j\rightarrow i$.</p> <p>The combinatorial structure is usually accompanied by numerical attributes:</p> <ul> <li>a node-feature matrix $X\in\mathbb{R}^{N\times F}$, whose $i$-th row is $x_i$;</li> <li>optional edge attributes $e_{ji}\in\mathbb{R}^{F_e}$;</li> <li>targets defined at node, edge, or graph level.</li> </ul> <p>For a molecular graph, nodes may denote atoms, edges may denote chemical bonds or spatial neighborhoods, and $x_i$ may encode atomic species and local descriptors. In a citation graph, nodes are documents, edges are citations, and node features may be derived from text.</p> <figure class="text-center"> <img class="img-fluid" src="/assets/img/blog/gnn-from-scratch/karate-club-graph.png" alt="Zachary's karate club graph, with nodes colored by community" loading="lazy"/> <figcaption><strong>Figure 1.</strong> Zachary's karate club network, a standard example of irregular graph data; colors denote communities obtained by modularity-based clustering. Source: <a href="https://tkipf.github.io/graph-convolutional-networks/">Thomas Kipf, <em>Graph Convolutional Networks</em> (2016)</a>.</figcaption> </figure> <h3 id="12-adjacency-matrices-and-sparse-edge-lists">1.2 Adjacency matrices and sparse edge lists</h3> <p>Fix the convention $A_{ij}=1$ when $j\rightarrow i$. The adjacency matrix $A\in\lbrace 0,1\rbrace^{N\times N}$ then encodes the graph, and the product $AX$ sums source-node features at their destinations. Although this notation is convenient for derivations, storing $A$ requires $O(N^2)$ memory.</p> <p>Most graphs of practical interest are sparse: $\lvert E\rvert\ll N^2$. A coordinate representation therefore stores two integer arrays,</p> \[\mathbf{s}=(s_1,\ldots,s_{\lvert E\rvert}), \qquad \mathbf{d}=(d_1,\ldots,d_{\lvert E\rvert}),\] <p>with $s_r\rightarrow d_r$ for edge $r$. In code, they are the tensors <code class="language-plaintext highlighter-rouge">src</code> and <code class="language-plaintext highlighter-rouge">dst</code>:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">torch</span>

<span class="c1"># Directed edges: 0→1, 1→0, 2→0, 2→3, 3→2
</span><span class="n">src</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">tensor</span><span class="p">([</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">])</span>
<span class="n">dst</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">tensor</span><span class="p">([</span><span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">2</span><span class="p">])</span>

<span class="n">x</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">tensor</span><span class="p">([</span>
    <span class="p">[</span><span class="mf">1.0</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.8</span><span class="p">,</span> <span class="mf">0.2</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.0</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">],</span>
    <span class="p">[</span><span class="mf">0.1</span><span class="p">,</span> <span class="mf">0.9</span><span class="p">],</span>
<span class="p">])</span>

<span class="c1"># Gather one source representation per edge: shape [|E|, F].
</span><span class="n">x_source</span> <span class="o">=</span> <span class="n">x</span><span class="p">[</span><span class="n">src</span><span class="p">]</span>
</code></pre></div></div> <p>The expression <code class="language-plaintext highlighter-rouge">x[src]</code> is a <strong>gather</strong> operation: it moves data from node space to edge space. The reverse operation reduces edge messages at their destinations.</p> <h3 id="13-learning-problems-on-graphs">1.3 Learning problems on graphs</h3> <p>Graph prediction tasks differ in the object to which the target is attached.</p> <table> <thead> <tr> <th>Task level</th> <th>Input-output example</th> <th>Typical final operation</th> </tr> </thead> <tbody> <tr> <td>Node</td> <td>Assign a function or class to each atom</td> <td>One prediction per node embedding</td> </tr> <tr> <td>Edge</td> <td>Predict whether two entities interact</td> <td>Combine the two endpoint embeddings</td> </tr> <tr> <td>Graph</td> <td>Predict a molecular energy or material property</td> <td>Pool all node embeddings, then regress</td> </tr> </tbody> </table> <p>The same message-passing backbone can serve all three settings. What changes is the prediction head and, for graph-level tasks, the readout that maps a variable number of nodes to a fixed-dimensional vector.</p> <h3 id="14-the-absence-of-a-canonical-node-order">1.4 The absence of a canonical node order</h3> <p>Node indices are bookkeeping devices, not spatial coordinates. If a permutation matrix $P$ relabels the nodes, then</p> \[X' = PX, \qquad A' = PAP^\top\] <p>describe the same attributed graph. A node-level graph network $f$ should be permutation equivariant:</p> \[f(PX,PAP^\top)=P f(X,A).\] <p>A graph-level predictor should be invariant to the same relabeling. Parameter sharing across nodes and symmetric neighborhood reductions—sum, mean, or maximum—provide these properties. Concatenating neighbors in an arbitrary stored order would not.</p> <h2 id="2-message-passing-as-local-computation">2. Message passing as local computation</h2> <p>Let $h_i^{(0)}=x_i$. A message-passing layer at depth $\ell$ can be separated into three maps [1]. For every edge $j\rightarrow i$, a message is constructed:</p> \[m_{j\rightarrow i}^{(\ell)} = M^{(\ell)}\!\left( h_i^{(\ell)},h_j^{(\ell)},e_{ji} \right). \tag{1}\] <p>Messages entering node $i$ are reduced by a permutation-invariant operator:</p> \[\bar m_i^{(\ell)} = \underset{j\in\mathcal N(i)}{\operatorname{AGG}} m_{j\rightarrow i}^{(\ell)}. \tag{2}\] <p>Finally, the node state is updated:</p> \[h_i^{(\ell+1)} = U^{(\ell)}\!\left(h_i^{(\ell)},\bar m_i^{(\ell)}\right). \tag{3}\] <figure class="text-center"> <img class="img-fluid" src="/assets/img/blog/gnn-from-scratch/gcn-architecture-kipf.png" alt="A multilayer graph convolutional network propagating node features over a fixed graph" loading="lazy"/> <figcaption><strong>Figure 2.</strong> A multilayer GCN repeatedly propagates and transforms node representations while preserving the graph structure. Source: <a href="https://tkipf.github.io/graph-convolutional-networks/">Thomas Kipf, <em>Graph Convolutional Networks</em> (2016)</a>.</figcaption> </figure> <p>Equations (1)–(3) translate directly into PyTorch. For sum aggregation:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">message_passing</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">message_fn</span><span class="p">,</span> <span class="n">update_fn</span><span class="p">,</span> <span class="n">edge_attr</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">source_x</span> <span class="o">=</span> <span class="n">x</span><span class="p">[</span><span class="n">src</span><span class="p">]</span>
    <span class="n">destination_x</span> <span class="o">=</span> <span class="n">x</span><span class="p">[</span><span class="n">dst</span><span class="p">]</span>

    <span class="k">if</span> <span class="n">edge_attr</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">messages</span> <span class="o">=</span> <span class="nf">message_fn</span><span class="p">(</span><span class="n">source_x</span><span class="p">,</span> <span class="n">destination_x</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">messages</span> <span class="o">=</span> <span class="nf">message_fn</span><span class="p">(</span><span class="n">source_x</span><span class="p">,</span> <span class="n">destination_x</span><span class="p">,</span> <span class="n">edge_attr</span><span class="p">)</span>

    <span class="n">aggregated</span> <span class="o">=</span> <span class="n">messages</span><span class="p">.</span><span class="nf">new_zeros</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="n">messages</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">))</span>
    <span class="n">aggregated</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">messages</span><span class="p">)</span>

    <span class="k">return</span> <span class="nf">update_fn</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">aggregated</span><span class="p">)</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">index_add_</code> is the essential sparse reduction: rows of <code class="language-plaintext highlighter-rouge">messages</code> sharing the same destination index are summed. Replacing it with a mean or elementwise maximum changes the aggregator but not the computational pattern.</p> <p>After one layer, $h_i^{(1)}$ depends on the one-hop neighborhood of $i$. After $L$ layers, information can travel along paths of at most $L$ edges. Depth therefore controls the receptive field, but it also repeatedly mixes node states; this becomes relevant when oversmoothing is considered later.</p> <h2 id="3-graph-convolutional-networks">3. Graph Convolutional Networks</h2> <h3 id="31-renormalized-graph-propagation">3.1 Renormalized graph propagation</h3> <p>Kipf and Welling derive GCN as a first-order approximation to localized spectral graph convolution [2]. The resulting layer has a particularly simple spatial interpretation. First add one self-loop per node:</p> \[\widetilde A=A+I_N. \tag{4}\] <p>The self-loop retains direct access to the node’s current representation during aggregation. Define the corresponding degree matrix</p> \[\widetilde D_{ii}=\sum_j \widetilde A_{ij}, \tag{5}\] <p>and the symmetrically normalized propagation operator</p> \[\widehat A=\widetilde D^{-1/2}\widetilde A\widetilde D^{-1/2}. \tag{6}\] <p>A GCN layer is then</p> \[H^{(\ell+1)}=\sigma\!\left( \widehat A H^{(\ell)}W^{(\ell)}+b^{(\ell)} \right). \tag{7}\] <p>Here $W^{(\ell)}\in\mathbb{R}^{F_\ell\times F_{\ell+1}}$ is learned, while $\widehat A$ is determined by graph structure. Equation (7) can be read at one destination node:</p> \[h_i^{(\ell+1)}=\sigma\!\left( \sum_{j\in\mathcal N(i)\cup\{i\}} \frac{1}{\sqrt{\widetilde d_i\widetilde d_j}} h_j^{(\ell)}W^{(\ell)}+b^{(\ell)} \right). \tag{8}\] <p>Thus GCN is a message-passing layer with</p> \[m_{j\rightarrow i}^{(\ell)}= \frac{1}{\sqrt{\widetilde d_i\widetilde d_j}} h_j^{(\ell)}W^{(\ell)},\] <p>sum aggregation, and a nonlinear update. The coefficient reduces the disproportionate influence of high-degree nodes. For an undirected graph, which is the setting assumed here, every edge is stored in both directions and the source and destination degrees are consistent with (6).</p> <h3 id="32-from-the-matrix-equation-to-edge-index-code">3.2 From the matrix equation to edge-index code</h3> <p>The normalization in (4)–(6) can be computed without materializing an $N\times N$ matrix:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">gcn_normalize</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">num_nodes</span><span class="p">):</span>
    <span class="n">nodes</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">arange</span><span class="p">(</span><span class="n">num_nodes</span><span class="p">,</span> <span class="n">device</span><span class="o">=</span><span class="n">src</span><span class="p">.</span><span class="n">device</span><span class="p">)</span>

    <span class="c1"># Equation (4): append i→i for every node i.
</span>    <span class="n">src_hat</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">cat</span><span class="p">([</span><span class="n">src</span><span class="p">,</span> <span class="n">nodes</span><span class="p">])</span>
    <span class="n">dst_hat</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">cat</span><span class="p">([</span><span class="n">dst</span><span class="p">,</span> <span class="n">nodes</span><span class="p">])</span>

    <span class="c1"># Equation (5): in-degree after adding self-loops.
</span>    <span class="n">degree</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">zeros</span><span class="p">(</span><span class="n">num_nodes</span><span class="p">,</span> <span class="n">device</span><span class="o">=</span><span class="n">src</span><span class="p">.</span><span class="n">device</span><span class="p">)</span>
    <span class="n">degree</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span>
        <span class="mi">0</span><span class="p">,</span>
        <span class="n">dst_hat</span><span class="p">,</span>
        <span class="n">torch</span><span class="p">.</span><span class="nf">ones_like</span><span class="p">(</span><span class="n">dst_hat</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">degree</span><span class="p">.</span><span class="n">dtype</span><span class="p">),</span>
    <span class="p">)</span>

    <span class="c1"># One value of D^(-1/2) A D^(-1/2) per stored edge.
</span>    <span class="n">norm</span> <span class="o">=</span> <span class="n">degree</span><span class="p">[</span><span class="n">src_hat</span><span class="p">].</span><span class="nf">rsqrt</span><span class="p">()</span> <span class="o">*</span> <span class="n">degree</span><span class="p">[</span><span class="n">dst_hat</span><span class="p">].</span><span class="nf">rsqrt</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">src_hat</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">norm</span>
</code></pre></div></div> <p>The layer first transforms all nodes, gathers transformed source states, weights them, and finally scatter-sums them at destinations:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">gcn_layer</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">weight</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">activation</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">src_hat</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">norm</span> <span class="o">=</span> <span class="nf">gcn_normalize</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">x</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="mi">0</span><span class="p">))</span>

    <span class="n">transformed</span> <span class="o">=</span> <span class="n">x</span> <span class="o">@</span> <span class="n">weight</span>
    <span class="n">messages</span> <span class="o">=</span> <span class="n">norm</span><span class="p">.</span><span class="nf">unsqueeze</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="n">transformed</span><span class="p">[</span><span class="n">src_hat</span><span class="p">]</span>

    <span class="n">out</span> <span class="o">=</span> <span class="n">transformed</span><span class="p">.</span><span class="nf">new_zeros</span><span class="p">(</span><span class="n">transformed</span><span class="p">.</span><span class="n">shape</span><span class="p">)</span>
    <span class="n">out</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">messages</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">bias</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">out</span> <span class="o">=</span> <span class="n">out</span> <span class="o">+</span> <span class="n">bias</span>
    <span class="k">return</span> <span class="nf">activation</span><span class="p">(</span><span class="n">out</span><span class="p">)</span> <span class="k">if</span> <span class="n">activation</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="n">out</span>
</code></pre></div></div> <p>The <code class="language-plaintext highlighter-rouge">index_add_</code> statement is the sparse counterpart of left multiplication by $\widehat A$. Autograd records the gather, multiplication, and reduction operations, so <code class="language-plaintext highlighter-rouge">loss.backward()</code> differentiates the layer without a graph-specific library.</p> <h3 id="33-interpretation-and-computational-cost">3.3 Interpretation and computational cost</h3> <p>GCN uses the same structural weighting rule for every sample and every feature channel. Its learnable component is the feature transformation $W^{(\ell)}$; its neighborhood coefficients depend only on degrees. With $F_\ell$ input and $F_{\ell+1}$ output channels, a sparse layer requires approximately</p> \[O\!\left(NF_\ell F_{\ell+1}+|E|F_{\ell+1}\right)\] <p>operations: one dense node-wise transform and one sparse propagation. This simplicity makes GCN a strong baseline, particularly on homophilous graphs where adjacent nodes tend to share labels or signals.</p> <p>Its restriction is equally clear. Two neighbors with the same degrees receive the same scalar structural coefficient, regardless of their current features or relevance to the prediction.</p> <h2 id="4-graph-attention-networks">4. Graph Attention Networks</h2> <h3 id="41-feature-dependent-neighborhood-weights">4.1 Feature-dependent neighborhood weights</h3> <p>GAT retains local aggregation but learns the coefficient assigned to each edge [3]. For one attention head, transform each node:</p> \[z_i=Wh_i, \qquad W\in\mathbb{R}^{F\times F'}. \tag{9}\] <p>For an edge $j\rightarrow i$, the original GAT score is</p> \[e_{ji}=\operatorname{LeakyReLU}\!\left( a^\top[z_j\,\Vert\,z_i] \right), \tag{10}\] <p>where $a\in\mathbb{R}^{2F’}$ is learned and $\Vert$ denotes concatenation. Partitioning $a=[a_{\mathrm{src}}\Vert a_{\mathrm{dst}}]$ avoids explicitly constructing the concatenated tensor:</p> \[e_{ji}=\operatorname{LeakyReLU}\!\left( a_{\mathrm{src}}^\top z_j+a_{\mathrm{dst}}^\top z_i \right). \tag{11}\] <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">torch.nn.functional</span> <span class="k">as</span> <span class="n">F</span>

<span class="k">def</span> <span class="nf">gat_logits</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">weight</span><span class="p">,</span> <span class="n">attn_src</span><span class="p">,</span> <span class="n">attn_dst</span><span class="p">):</span>
    <span class="n">z</span> <span class="o">=</span> <span class="n">x</span> <span class="o">@</span> <span class="n">weight</span>                                  <span class="c1"># Equation (9)
</span>    <span class="n">logits</span> <span class="o">=</span> <span class="p">(</span>
        <span class="p">(</span><span class="n">z</span><span class="p">[</span><span class="n">src</span><span class="p">]</span> <span class="o">*</span> <span class="n">attn_src</span><span class="p">).</span><span class="nf">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
        <span class="o">+</span> <span class="p">(</span><span class="n">z</span><span class="p">[</span><span class="n">dst</span><span class="p">]</span> <span class="o">*</span> <span class="n">attn_dst</span><span class="p">).</span><span class="nf">sum</span><span class="p">(</span><span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">F</span><span class="p">.</span><span class="nf">leaky_relu</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">negative_slope</span><span class="o">=</span><span class="mf">0.2</span><span class="p">),</span> <span class="n">z</span>
</code></pre></div></div> <p>The score is meaningful only relative to other edges entering the same node. A neighborhood-wise softmax gives</p> \[\alpha_{ji}= \frac{\exp(e_{ji})} {\sum_{k\in\mathcal N(i)\cup\{i\}}\exp(e_{ki})}, \qquad \sum_{j\in\mathcal N(i)\cup\{i\}}\alpha_{ji}=1. \tag{12}\] <p>The grouping by destination is essential. A single softmax over all graph edges would couple unrelated neighborhoods and would not implement (12).</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">neighborhood_softmax</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">num_nodes</span><span class="p">):</span>
    <span class="n">alpha</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">zeros_like</span><span class="p">(</span><span class="n">logits</span><span class="p">)</span>

    <span class="c1"># Deliberately explicit; optimized implementations use segment kernels.
</span>    <span class="k">for</span> <span class="n">node</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">num_nodes</span><span class="p">):</span>
        <span class="n">incoming</span> <span class="o">=</span> <span class="n">dst</span> <span class="o">==</span> <span class="n">node</span>
        <span class="k">if</span> <span class="n">incoming</span><span class="p">.</span><span class="nf">any</span><span class="p">():</span>
            <span class="n">alpha</span><span class="p">[</span><span class="n">incoming</span><span class="p">]</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">softmax</span><span class="p">(</span><span class="n">logits</span><span class="p">[</span><span class="n">incoming</span><span class="p">],</span> <span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">alpha</span>
</code></pre></div></div> <p>The destination receives the attention-weighted sum</p> \[h_i'=\sigma\!\left( \sum_{j\in\mathcal N(i)\cup\{i\}} \alpha_{ji}z_j \right). \tag{13}\] <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">gat_head</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">src</span><span class="p">,</span> <span class="n">dst</span><span class="p">,</span> <span class="n">weight</span><span class="p">,</span> <span class="n">attn_src</span><span class="p">,</span> <span class="n">attn_dst</span><span class="p">,</span>
             <span class="n">bias</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">activation</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="c1"># GAT normally attends to the node itself as well.
</span>    <span class="n">nodes</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">arange</span><span class="p">(</span><span class="n">x</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="n">device</span><span class="o">=</span><span class="n">x</span><span class="p">.</span><span class="n">device</span><span class="p">)</span>
    <span class="n">src_hat</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">cat</span><span class="p">([</span><span class="n">src</span><span class="p">,</span> <span class="n">nodes</span><span class="p">])</span>
    <span class="n">dst_hat</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">cat</span><span class="p">([</span><span class="n">dst</span><span class="p">,</span> <span class="n">nodes</span><span class="p">])</span>

    <span class="n">logits</span><span class="p">,</span> <span class="n">z</span> <span class="o">=</span> <span class="nf">gat_logits</span><span class="p">(</span>
        <span class="n">x</span><span class="p">,</span> <span class="n">src_hat</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">weight</span><span class="p">,</span> <span class="n">attn_src</span><span class="p">,</span> <span class="n">attn_dst</span>
    <span class="p">)</span>
    <span class="n">alpha</span> <span class="o">=</span> <span class="nf">neighborhood_softmax</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">x</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="mi">0</span><span class="p">))</span>

    <span class="n">messages</span> <span class="o">=</span> <span class="n">alpha</span><span class="p">.</span><span class="nf">unsqueeze</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="n">z</span><span class="p">[</span><span class="n">src_hat</span><span class="p">]</span>
    <span class="n">out</span> <span class="o">=</span> <span class="n">z</span><span class="p">.</span><span class="nf">new_zeros</span><span class="p">(</span><span class="n">z</span><span class="p">.</span><span class="n">shape</span><span class="p">)</span>
    <span class="n">out</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">dst_hat</span><span class="p">,</span> <span class="n">messages</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">bias</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">out</span> <span class="o">=</span> <span class="n">out</span> <span class="o">+</span> <span class="n">bias</span>
    <span class="k">if</span> <span class="n">activation</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">out</span> <span class="o">=</span> <span class="nf">activation</span><span class="p">(</span><span class="n">out</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">out</span><span class="p">,</span> <span class="n">alpha</span>
</code></pre></div></div> <figure class="text-center"> <img class="img-fluid" src="/assets/img/blog/gnn-from-scratch/gat-layer-velickovic.png" alt="A multi-head graph attention layer aggregating differently weighted neighboring representations" loading="lazy"/> <figcaption><strong>Figure 3.</strong> A multi-head graph attention update. Each head assigns its own normalized coefficients to the incoming neighbors; head outputs are concatenated or averaged. Source: <a href="https://github.com/PetarV-/GAT">official GAT repository, Veličković et al.</a></figcaption> </figure> <h3 id="42-multiple-attention-heads">4.2 Multiple attention heads</h3> <p>GAT stabilizes learning by using $K$ independently parameterized heads. Hidden layers usually concatenate their outputs,</p> \[h_i'=\mathbin\Vert_{k=1}^{K} \sigma\!\left( \sum_{j\in\mathcal N(i)\cup\{i\}} \alpha_{ji}^{(k)}W^{(k)}h_j \right), \tag{14}\] <p>whereas a final layer may average them:</p> \[h_i'=\frac{1}{K}\sum_{k=1}^{K} \sum_{j\in\mathcal N(i)\cup\{i\}} \alpha_{ji}^{(k)}W^{(k)}h_j. \tag{15}\] <p>In PyTorch, the merge itself is elementary:</p> <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">hidden</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">cat</span><span class="p">(</span><span class="n">head_outputs</span><span class="p">,</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>           <span class="c1"># Equation (14)
</span><span class="n">prediction</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="nf">stack</span><span class="p">(</span><span class="n">head_outputs</span><span class="p">).</span><span class="nf">mean</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span> <span class="c1"># Equation (15)
</span></code></pre></div></div> <p>Each head adds its own node transform and edge scores. The approximate sparse cost is</p> \[O\!\left( K\left[NFF'+\lvert E\rvert F'\right] \right),\] <p>and $K\lvert E\rvert$ attention values must be represented during the forward pass. GAT is consequently more flexible than GCN, but not free.</p> <h3 id="43-what-attention-doesand-does-notprovide">4.3 What attention does—and does not—provide</h3> <p>The coefficients $\alpha_{ji}$ are conditional on learned representations and can reveal which neighbors receive large weights in a particular layer. They are not, by themselves, a causal or faithful explanation of the final prediction. Later transformations, nonlinearities, redundant features, and interactions between heads can all alter their interpretation. Attention should therefore be inspected as an internal routing quantity, not treated automatically as an explanation [5].</p> <h2 id="5-from-node-representations-to-predictions">5. From node representations to predictions</h2> <p>For semi-supervised node classification, a final graph layer returns logits $o_i\in\mathbb{R}^{C}$. Cross-entropy is evaluated only on labeled training nodes $V_{\mathrm{train}}$:</p> \[\mathcal L_{\mathrm{node}}= -\frac{1}{|V_{\mathrm{train}}|} \sum_{i\in V_{\mathrm{train}}} \log\frac{\exp(o_{i,y_i})}{\sum_{c=1}^{C}\exp(o_{i,c})}. \tag{16}\] <p>For graph regression, node states must first be pooled. A mean readout for graph $G_b$ is</p> \[g_b=\frac{1}{|V_b|}\sum_{i\in V_b}h_i^{(L)}, \qquad \widehat y_b=w^\top g_b+c. \tag{17}\] <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">global_mean_pool</span><span class="p">(</span><span class="n">node_embeddings</span><span class="p">,</span> <span class="n">batch</span><span class="p">,</span> <span class="n">num_graphs</span><span class="p">):</span>
    <span class="n">pooled</span> <span class="o">=</span> <span class="n">node_embeddings</span><span class="p">.</span><span class="nf">new_zeros</span><span class="p">(</span><span class="n">num_graphs</span><span class="p">,</span> <span class="n">node_embeddings</span><span class="p">.</span><span class="nf">size</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">))</span>
    <span class="n">pooled</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">batch</span><span class="p">,</span> <span class="n">node_embeddings</span><span class="p">)</span>

    <span class="n">count</span> <span class="o">=</span> <span class="n">node_embeddings</span><span class="p">.</span><span class="nf">new_zeros</span><span class="p">(</span><span class="n">num_graphs</span><span class="p">)</span>
    <span class="n">count</span><span class="p">.</span><span class="nf">index_add_</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">batch</span><span class="p">,</span> <span class="n">torch</span><span class="p">.</span><span class="nf">ones_like</span><span class="p">(</span><span class="n">batch</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">count</span><span class="p">.</span><span class="n">dtype</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">pooled</span> <span class="o">/</span> <span class="n">count</span><span class="p">.</span><span class="nf">clamp_min</span><span class="p">(</span><span class="mi">1</span><span class="p">).</span><span class="nf">unsqueeze</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div> <p>The pooling operation must be permutation invariant. Sum, mean, and maximum are common choices, but they encode different assumptions about graph size and multiplicity.</p> <h2 id="6-controlled-gcngat-comparison">6. Controlled GCN–GAT comparison</h2> <p>The companion code includes a small benchmark intended to expose model behavior, not to establish a general ranking. All data are synthetic; every value below is a mean $\pm$ sample standard deviation over seeds $0,1,2$.</p> <h3 id="61-experimental-protocol">6.1 Experimental protocol</h3> <table> <thead> <tr> <th>Component</th> <th>Protocol</th> </tr> </thead> <tbody> <tr> <td>Node classification</td> <td>One 90-node, balanced three-class stochastic block model per seed; stratified 60%/20%/20% split</td> </tr> <tr> <td>Homophily regimes</td> <td>High: $p_{\mathrm{in}}=0.35$, $p_{\mathrm{out}}=0.02$; moderate: $0.25/0.08$; low: $0.18/0.12$</td> </tr> <tr> <td>Graph regression</td> <td>72 graphs per seed, each with 8–14 nodes; 70%/15%/remainder split</td> </tr> <tr> <td>Capacity</td> <td>Two message-passing layers, hidden width 16; one attention head for GAT</td> </tr> <tr> <td>Training</td> <td>Adam, validation-loss early stopping, identical data splits and stopping rules</td> </tr> <tr> <td>Deep-model diagnostic</td> <td>Separate six-layer classifiers in the moderate-homophily regime</td> </tr> </tbody> </table> <p>Holding width and depth fixed does not make the parameter counts identical: attention introduces additional vectors. The node classifiers contain 467 parameters for GCN and 531 for GAT.</p> <h3 id="62-node-classification">6.2 Node classification</h3> <table> <thead> <tr> <th>Homophily</th> <th>Model</th> <th style="text-align: right">Test accuracy $\uparrow$</th> <th style="text-align: right">Macro-F1 $\uparrow$</th> </tr> </thead> <tbody> <tr> <td>High</td> <td>GCN</td> <td style="text-align: right">$0.981\pm0.032$</td> <td style="text-align: right">$0.981\pm0.032$</td> </tr> <tr> <td>High</td> <td>GAT</td> <td style="text-align: right"><strong>$1.000\pm0.000$</strong></td> <td style="text-align: right"><strong>$1.000\pm0.000$</strong></td> </tr> <tr> <td>Moderate</td> <td>GCN</td> <td style="text-align: right"><strong>$0.593\pm0.179$</strong></td> <td style="text-align: right"><strong>$0.576\pm0.181$</strong></td> </tr> <tr> <td>Moderate</td> <td>GAT</td> <td style="text-align: right">$0.574\pm0.085$</td> <td style="text-align: right">$0.542\pm0.108$</td> </tr> <tr> <td>Low</td> <td>GCN</td> <td style="text-align: right"><strong>$0.333\pm0.111$</strong></td> <td style="text-align: right"><strong>$0.269\pm0.062$</strong></td> </tr> <tr> <td>Low</td> <td>GAT</td> <td style="text-align: right">$0.315\pm0.085$</td> <td style="text-align: right">$0.243\pm0.105$</td> </tr> </tbody> </table> <p>Both architectures solve the high-homophily setting, where adjacency is strongly aligned with class membership. As $p_{\mathrm{in}}-p_{\mathrm{out}}$ narrows, the graph becomes less informative and performance approaches the balanced three-class chance accuracy of $1/3$.</p> <p>The moderate- and low-homophily means favor GCN slightly, but the uncertainty across three seeds is large. These results do not support the claim that one architecture is generally superior. They show instead that learnable attention cannot recover class information that is largely absent from both features and local connectivity.</p> <h3 id="63-graph-regression">6.3 Graph regression</h3> <p>The graph target combines node features and degree statistics. Targets are standardized using training-set statistics and transformed back to their original scale for evaluation.</p> <table> <thead> <tr> <th>Model</th> <th style="text-align: right">Test MAE $\downarrow$</th> <th style="text-align: right">Test RMSE $\downarrow$</th> <th style="text-align: right">Test $R^2$ $\uparrow$</th> </tr> </thead> <tbody> <tr> <td>GCN</td> <td style="text-align: right"><strong>$0.162\pm0.025$</strong></td> <td style="text-align: right"><strong>$0.214\pm0.017$</strong></td> <td style="text-align: right"><strong>$0.250\pm0.365$</strong></td> </tr> <tr> <td>GAT</td> <td style="text-align: right">$0.171\pm0.074$</td> <td style="text-align: right">$0.215\pm0.097$</td> <td style="text-align: right">$0.114\pm0.713$</td> </tr> </tbody> </table> <p>The mean absolute and root-mean-square errors are close relative to their variation. The unstable $R^2$ values reflect a small synthetic test set. A larger study would require more graphs, more seeds, capacity-matched variants, and confidence intervals chosen before interpreting model differences.</p> <h3 id="64-oversmoothing">6.4 Oversmoothing</h3> <p>Repeated neighborhood averaging can make node representations increasingly similar. Two complementary statistics are recorded after every layer. Mean off-diagonal cosine similarity is</p> \[S^{(\ell)}= \frac{1}{N(N-1)} \sum_{i\ne j} \frac{\left(h_i^{(\ell)}\right)^\top h_j^{(\ell)}} {\|h_i^{(\ell)}\|_2\,\|h_j^{(\ell)}\|_2}, \tag{18}\] <p>and normalized dispersion is</p> \[D^{(\ell)}= \frac{\sum_i\|h_i^{(\ell)}-\bar h^{(\ell)}\|_2^2} {\sum_i\|h_i^{(\ell)}\|_2^2+\varepsilon}. \tag{19}\] <p>Larger $S^{(\ell)}$ and smaller $D^{(\ell)}$ indicate more aligned representations.</p> <figure class="text-center"> <img class="img-fluid" src="/assets/img/blog/gnn-from-scratch/oversmoothing-cosine.svg" alt="Mean pairwise cosine similarity by layer for six-layer GCN and GAT models" loading="lazy"/> <figcaption><strong>Figure 4.</strong> Mean pairwise cosine similarity by message-passing depth. Curves show the mean over three seeds; shaded regions show one sample standard deviation.</figcaption> </figure> <figure class="text-center"> <img class="img-fluid" src="/assets/img/blog/gnn-from-scratch/oversmoothing-dispersion.svg" alt="Normalized node dispersion by layer for six-layer GCN and GAT models" loading="lazy"/> <figcaption><strong>Figure 5.</strong> Normalized node dispersion by message-passing depth. In this single-head experiment, GAT representations lose node-wise dispersion earlier than GCN representations.</figcaption> </figure> <table> <thead> <tr> <th style="text-align: right">Layer</th> <th style="text-align: right">GCN cosine $\uparrow$</th> <th style="text-align: right">GAT cosine $\uparrow$</th> <th style="text-align: right">GCN dispersion $\downarrow$</th> <th style="text-align: right">GAT dispersion $\downarrow$</th> </tr> </thead> <tbody> <tr> <td style="text-align: right">1</td> <td style="text-align: right">$0.555\pm0.075$</td> <td style="text-align: right">$0.615\pm0.056$</td> <td style="text-align: right">$0.523\pm0.026$</td> <td style="text-align: right">$0.441\pm0.025$</td> </tr> <tr> <td style="text-align: right">2</td> <td style="text-align: right">$0.745\pm0.186$</td> <td style="text-align: right">$0.916\pm0.118$</td> <td style="text-align: right">$0.328\pm0.229$</td> <td style="text-align: right">$0.101\pm0.126$</td> </tr> <tr> <td style="text-align: right">3</td> <td style="text-align: right">$0.787\pm0.179$</td> <td style="text-align: right">$0.900\pm0.171$</td> <td style="text-align: right">$0.298\pm0.250$</td> <td style="text-align: right">$0.110\pm0.186$</td> </tr> <tr> <td style="text-align: right">4</td> <td style="text-align: right">$0.815\pm0.162$</td> <td style="text-align: right">$0.946\pm0.093$</td> <td style="text-align: right">$0.271\pm0.227$</td> <td style="text-align: right">$0.059\pm0.102$</td> </tr> <tr> <td style="text-align: right">5</td> <td style="text-align: right">$0.887\pm0.125$</td> <td style="text-align: right">$0.950\pm0.087$</td> <td style="text-align: right">$0.242\pm0.220$</td> <td style="text-align: right">$0.057\pm0.098$</td> </tr> <tr> <td style="text-align: right">6</td> <td style="text-align: right">$0.815\pm0.179$</td> <td style="text-align: right">$0.934\pm0.114$</td> <td style="text-align: right">$0.265\pm0.258$</td> <td style="text-align: right">$0.072\pm0.125$</td> </tr> </tbody> </table> <p>At layer six, test accuracy is $0.593\pm0.280$ for GCN and $0.481\pm0.257$ for GAT. Attention has not prevented representation alignment in this configuration. This is not a universal statement about GAT: it is an observation for one synthetic distribution, one head, one width, and one optimization protocol. Its value is methodological—oversmoothing should be measured rather than inferred from the architecture’s name.</p> <h2 id="7-structural-comparison">7. Structural comparison</h2> <table> <thead> <tr> <th>Property</th> <th>GCN</th> <th>GAT</th> </tr> </thead> <tbody> <tr> <td>Edge coefficient</td> <td>$1/\sqrt{\widetilde d_i\widetilde d_j}$</td> <td>$\alpha_{ji}$ from endpoint states</td> </tr> <tr> <td>Learned quantities</td> <td>Feature transform $W$</td> <td>$W$ and attention vector $a$ per head</td> </tr> <tr> <td>Neighborhood normalization</td> <td>Symmetric degree normalization</td> <td>Destination-wise softmax</td> </tr> <tr> <td>Data dependence</td> <td>Coefficients fixed for a given graph</td> <td>Coefficients change with node states</td> </tr> <tr> <td>Sparse memory</td> <td>$O(\lvert E\rvert)$ coefficients</td> <td>$O(K\lvert E\rvert)$ attention coefficients</td> </tr> <tr> <td>Principal strength</td> <td>Simplicity and parameter efficiency</td> <td>Adaptive neighbor weighting</td> </tr> <tr> <td>Principal limitation</td> <td>Same structural rule for all examples</td> <td>Additional parameters and edge-wise computation</td> </tr> </tbody> </table> <p>The models share more than this table may suggest. Both are local, permutation-equivariant message-passing networks. Both require a deliberate self-loop convention. Both depend on the information present in the chosen neighborhood graph. Both can lose discriminative node information with depth. The choice between them is therefore empirical and should be made under a controlled protocol rather than from the expectation that attention must improve every task.</p> <h2 id="8-reproducibility-and-implementation-map">8. Reproducibility and implementation map</h2> <p>The <a href="https://github.com/rizkisalaheddine/message-passing-gnns-from-scratch">full repository</a> separates the implementation into graph primitives, generic message passing, graph convolution, graph attention, readout, datasets, optimization, and experiments. It contains:</p> <ul> <li>sparse COO graph construction and gather/scatter reductions;</li> <li>GCN normalization and propagation;</li> <li>single- and multi-head GAT operations;</li> <li>node-classification and graph-regression pipelines;</li> <li>raw multi-seed benchmark results and oversmoothing diagnostics.</li> </ul> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/rizkisalaheddine/message-passing-gnns-from-scratch.git
<span class="nb">cd </span>message-passing-gnns-from-scratch
python <span class="nt">-m</span> venv .venv
<span class="nb">source</span> .venv/bin/activate
python <span class="nt">-m</span> pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt
python demo.py
</code></pre></div></div> <p>To reproduce the comparison tables:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python <span class="nt">-m</span> gnn_from_scratch.experiments.benchmark <span class="se">\</span>
  <span class="nt">--output</span> results/gcn_gat_benchmark.json
</code></pre></div></div> <p>The code favors inspectability over optimized segment kernels. Its CPU timings should not be compared with PyTorch Geometric or other production libraries; the purpose is to expose the computation that those libraries implement efficiently.</p> <h2 id="9-conclusion">9. Conclusion</h2> <p>Message passing provides a compact language for neural computation on graphs: construct an edge-wise message, reduce messages at destinations, and update node states. GCN instantiates this scheme with fixed symmetric degree normalization. GAT replaces the fixed coefficient with a learned neighborhood softmax, usually replicated across several heads.</p> <p>Writing both layers with <code class="language-plaintext highlighter-rouge">src</code>, <code class="language-plaintext highlighter-rouge">dst</code>, gather operations, and <code class="language-plaintext highlighter-rouge">index_add_</code> makes their relationship explicit. The apparent difference between graph convolution and graph attention is not a difference in computational substrate; it is primarily a choice of how local messages are weighted. That observation is the useful starting point for more elaborate models involving edge features, geometric equivariance, heterogeneous relations, or learned molecular interactions.</p> <h2 id="references">References</h2> <ol> <li>J. Gilmer, S. S. Schoenholz, P. F. Riley, O. Vinyals, and G. E. Dahl, <a href="https://proceedings.mlr.press/v70/gilmer17a.html">“Neural Message Passing for Quantum Chemistry,”</a> <em>Proceedings of ICML</em>, 2017.</li> <li>T. N. Kipf and M. Welling, <a href="https://arxiv.org/abs/1609.02907">“Semi-Supervised Classification with Graph Convolutional Networks,”</a> <em>Proceedings of ICLR</em>, 2017.</li> <li>P. Veličković et al., <a href="https://arxiv.org/abs/1710.10903">“Graph Attention Networks,”</a> <em>Proceedings of ICLR</em>, 2018.</li> <li>K. Oono and T. Suzuki, <a href="https://arxiv.org/abs/1905.10947">“Graph Neural Networks Exponentially Lose Expressive Power for Node Classification,”</a> <em>Proceedings of ICLR</em>, 2020.</li> <li>S. Jain and B. C. Wallace, <a href="https://aclanthology.org/N19-1357/">“Attention is not Explanation,”</a> <em>Proceedings of NAACL-HLT</em>, 2019.</li> </ol> <h3 id="pedagogical-sources">Pedagogical sources</h3> <ul> <li>F. Karami, <a href="https://medium.com/@farzad.karami/understanding-graph-attention-networks-a-practical-exploration-cf033a8f3d9d">“Understanding Graph Attention Networks: A Practical Exploration,”</a> 2023.</li> <li>M. Labonne, <a href="https://medium.com/data-science/graph-convolutional-networks-introduction-to-gnns-24b3f60d6c95">“Graph Convolutional Networks: Introduction to GNNs,”</a> 2023.</li> </ul>]]></content><author><name></name></author><category term="graph-learning"/><category term="graph-neural-networks"/><category term="pytorch"/><category term="geometric-deep-learning"/><summary type="html"><![CDATA[A mathematical and implementation-oriented introduction to message passing, graph convolution, and graph attention without PyTorch Geometric.]]></summary></entry></feed>