纯NumPy手写两层GCN:从零开始理解图神经网络核心思想

摘要

很多教程直接调用GCNConv,但你真的理解 <math xmlns="http://www.w3.org/1998/Math/MathML"> D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>D~−1/2A~D~−1/2在做什么吗? 本文用纯NumPy从零实现两层GCN ,在34节点的空手道俱乐部图上完成半监督节点分类。通过手写对称归一化邻接矩阵、数值梯度下降和可视化,深入讲解图卷积的核心机制。实验表明,仅标注2个节点即可达到**96.88%**的测试准确率。

一、为什么要从零手写GCN?

CNN处理图像(规则的像素网格),RNN处理文本(时间序列),但现实中还有大量的图数据 :社交网络、分子结构、交通路网......它们是不规则的,这就需要用到图神经网络了。图神经网络近年来火得一塌糊涂,但很多入门教程直接上PyTorch Geometric,封装的太好反而看不清图神经网络的本质。图神经网络的核心,就是每个节点通过边从邻居那里"借来"信息,更新自己的表示。听起来似乎很玄乎,其实本质就是矩阵乘法。

本文只用NumPy实现这一切,全程不依赖任何深度学习框架,所有公式都摊开给你看,让你彻底理解邻接矩阵归一化、消息传递、ReLU、Softmax交叉熵以及梯度下降这些组件是如何协同工作的,代码不到100行,可直接运行。读完你就会明白:

  • 那坨 <math xmlns="http://www.w3.org/1998/Math/MathML"> D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>D~−1/2A~D~−1/2到底是干嘛的?
  • 为什么随便初始化一个 W 矩阵,就能把节点分类?
  • 怎样只标注1~2个节点,让模型自己猜出全图标签?

二、数据准备:空手道俱乐部与度的独热编码

2.1、加载图并获取邻接矩阵

Zachary空手道俱乐部图是图学习的"Hello World"。34个节点代表俱乐部成员,78条边代表社交关系,后因教练(节点0)与主管(节点33)争执分裂成两个社区。

python 复制代码
import networkx as nx
import numpy as np

G = nx.karate_club_graph()
A = nx.to_numpy_array(G)          # 34×34 邻接矩阵,0/1
labels = np.array([G.nodes[i]['club'] for i in range(34)])
labels = (labels == 'Mr. Hi').astype(int)   # 0/1 标签

2.2、构造节点特征:度独热编码

这个图没有原始属性(年龄、兴趣等),必须给每个节点一个初始特征。最简单的办法是度的独热编码:每个节点的度(朋友数)用一个编码表示。

python 复制代码
degrees = np.array([d for _, d in G.degree()])
max_deg = degrees.max()
X = np.eye(max_deg + 1)[degrees]   # (34, max_deg+1) 独热矩阵

比如节点0度=16,max_deg=17,则节点0的特征就是18维向量,第16位是1,其余是0,这样每个节点的初始表示就有了区分度。

2.3、半监督设定:只标注极少节点

为了展现GCN的半监督传播能力,我们只标注节点0(标签0,代表'Mr. Hi'),再加一个节点33(标签1,代表'Officer')以防止训练坍缩。训练掩码定义如下:

python 复制代码
train_mask = np.zeros(34, dtype=bool)
train_mask[0] = True  
train_mask[33] = True   

三、GCN的灵魂:对称归一化邻接矩阵

3.1、为什么不能直接用原始 A?

直接用 A @ X 聚合邻居特征有两个问题:

  • 节点自己没参与(对角线全是0),自身特征被忽略。
  • 高度节点(如节点0有16个朋友)聚合后特征数值会很大,低度节点很小,造成数值不稳定。因此经典GCN做了两步改造:加自环 + 对称归一化

3.2、三步构造归一化矩阵

第一步:加自环

python 复制代码
A_tilde = A + np.eye(34)   # 对角线变成1

第二步:计算度矩阵并求逆平方根

python 复制代码
D_tilde = np.diag(np.sum(A_tilde, axis=1))         # 对角矩阵,对角线是每个节点的度(加自环后)
D_inv_sqrt = np.linalg.inv(np.sqrt(D_tilde))       # D^{-1/2}

第三步:对称归一化

python 复制代码
A_norm = D_inv_sqrt @ A_tilde @ D_inv_sqrt

"为简化符号,后文统一用 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ~ \tilde{A} </math>A~ 表示已完成对称归一化的邻接矩阵 <math xmlns="http://www.w3.org/1998/Math/MathML"> A n o r m A_{norm} </math>Anorm,即
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> A ~ = D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{A} = \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>A~=D~−1/2A~D~−1/2

这个 A_norm 就是最终的"卷积核"。对于节点i和节点j之间的边,权重变为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> A ~ i j = 1 D ~ i i ⋅ D ~ j j \tilde{A}{ij} = \frac{1}{\sqrt{\tilde{D}{ii} \cdot \tilde{D}_{jj}}} </math>A~ij=D~ii⋅D~jj 1

  • 原来有边的地方,变成一个小数(如 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 / 10 = 0.316 1/\sqrt{10} = 0.316 </math>1/10 =0.316)
  • 没有边的地方仍是0
  • 对角线上是 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 / D i i 1/D_{ii} </math>1/Dii

3.3、数学直觉:为什么归一化因子是√D?

对称归一化 <math xmlns="http://www.w3.org/1998/Math/MathML"> D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>D~−1/2A~D~−1/2 的本质是同时约束行和列: 左乘让"发送者"的度归一化,右乘让"接收者"的度归一化。如果只用一个 <math xmlns="http://www.w3.org/1998/Math/MathML"> D − 1 D^{-1} </math>D−1,相当于只有单向约束,高度节点仍然会"淹没"低度节点。

小知识: <math xmlns="http://www.w3.org/1998/Math/MathML"> A ~ norm \tilde{A}_{\text{norm}} </math>A~norm 与归一化图拉普拉斯
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> L = I − D ~ − 1 / 2 A ~ D ~ − 1 / 2 \mathcal{L} = I - \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>L=I−D~−1/2A~D~−1/2

有直接关系,GCN 正是基于 <math xmlns="http://www.w3.org/1998/Math/MathML"> L \mathcal{L} </math>L 的谱分解的一阶近似。感兴趣的读者可以延伸阅读 Kipf 论文的附录。

四、两层GCN向前传播

4.1、单层公式

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> H ( l + 1 ) = ReLU ( A ~ H ( l ) W ( l ) ) H^{(l+1)} = \text{ReLU}(\tilde{A} H^{(l)} W^{(l)}) </math>H(l+1)=ReLU(A~H(l)W(l))

  • <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( l ) H^{(l)} </math>H(l):第 <math xmlns="http://www.w3.org/1998/Math/MathML"> l l </math>l 层的节点表示矩阵,初始 <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( 0 ) = X H^{(0)} = X </math>H(0)=X
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> W ( l ) W^{(l)} </math>W(l):可学习的权重矩阵
  • <math xmlns="http://www.w3.org/1998/Math/MathML"> ReLU ( x ) = max ⁡ ( 0 , x ) \text{ReLU}(x) = \max(0, x) </math>ReLU(x)=max(0,x):非线性激活,负值被截断为0,为网络引入非线性,否则多层叠加仍等价于单层线性变换。

消息传递过程: <math xmlns="http://www.w3.org/1998/Math/MathML"> A norm @ H @ W A_{\text{norm}} @ H @ W </math>Anorm@H@W 就是每个节点先从邻居(含自己)聚合特征,再通过 <math xmlns="http://www.w3.org/1998/Math/MathML"> W W </math>W 做线性变换,最后过激活函数。

4.2、代码实现

python 复制代码
input_dim = X.shape[1]   # 18(最大度+1)
hidden_dim = 2           # 设为2方便直接画图,也可以调大
output_dim = 2           # 二分类

np.random.seed(42)
W1 = np.random.randn(input_dim, hidden_dim) * 0.01
W2 = np.random.randn(hidden_dim, output_dim) * 0.01

def softmax(x):
    e = np.exp(x - np.max(x, axis=1, keepdims=True))
    return e / np.sum(e, axis=1, keepdims=True)

# 前向传播
H = np.maximum(0, A_norm @ X @ W1)   # 第一层 → ReLU
out = A_norm @ H @ W2                # 第二层 logits
pred = softmax(out)                  # 概率

Softmax 把原始输出(logits)压成概率分布:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> s o f t m a x ( x i ) = e x i ∑ j e x j \mathrm{softmax}(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}} </math>softmax(xi)=∑jexjexi

代码里先减 <math xmlns="http://www.w3.org/1998/Math/MathML"> m a x \mathrm{max} </math>max 是为了防止指数爆炸,这是数值稳定技巧。

<math xmlns="http://www.w3.org/1998/Math/MathML"> h i d d e n _ d i m = 2 hidden\_dim=2 </math>hidden_dim=2 纯粹是为了之后把中间嵌入画在二维平面上,实际上你可以设任意值(比如5或许能得到更高准确率)。

4.3、直觉:为什么GCN能传播标签?

想象在一张白纸上滴一滴墨水,墨水会沿着纸的纤维慢慢扩散,原本无色区域也逐渐带上颜色。GCN传播标签的原理就像墨水扩散,只不过"纸"是图结构,"墨水"是标签信息。

每一层传播 A_norm @ H 的本质是:每个节点将自己的特征与邻居的特征加权平均。如果某个节点已经携带了强烈的"类别0"信号,经过一次聚合,它的邻居也会被染上一点"类别0"的信息;第二次聚合,邻居的邻居也被染上......一层层下去,只要图是连通的,最终所有节点都会共享一部分那个初始标签的信息。

再看公式:即使只有节点0和节点33被标注,训练时损失函数只惩罚节点0和节点33的错误。梯度下降会调整权重 W1, W2,使得节点0和节点33的预测越来越准。而节点0和节点33的预测变准后,它自身的嵌入 H[0] 就会偏向正确类别。在下一次前向传播时,邻居节点(如节点1、2、3...)通过 A_norm 聚合时把节点0和节点33的嵌入"平均"到了自己身上,于是它们也间接获得了类别倾向。这种倾向再通过第二层聚合继续向外扩散,最终全图都获得了分类能力。

所以,GCN的半监督学习并不是魔法,而是利用图结构把少量标签信息"抹匀"到所有节点。这也是为什么归一化邻接矩阵如此重要:它控制了墨水扩散的速度和均匀度,避免一个高度节点像大水漫灌一样冲淡信号。

五、训练:交叉熵损失 + 数值梯度下降

5.1、损失函数(半监督版本)

我们只关心有标签的节点,因此损失只在 mask 为 True 的节点上计算。对于节点 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i,设其真实标签为
<math xmlns="http://www.w3.org/1998/Math/MathML"> y i ∈ { 0 , 1 } y_i \in \{0,1\} </math>yi∈{0,1},模型预测的概率为 <math xmlns="http://www.w3.org/1998/Math/MathML"> p i , y i p_{i,y_i} </math>pi,yi,则半监督交叉熵损失为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> L = − 1 ∣ V train ∣ ∑ i ∈ V train log ⁡ ( p i , y i + ϵ ) \mathcal{L} = -\frac{1}{|\mathcal{V}{\text{train}}|} \sum{i \in \mathcal{V}{\text{train}}} \log(p{i,y_i} + \epsilon) </math>L=−∣Vtrain∣1i∈Vtrain∑log(pi,yi+ϵ)

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> V train \mathcal{V}_{\text{train}} </math>Vtrain 是被 train_mask 选中的节点集合(本文只有 2 个节点), <math xmlns="http://www.w3.org/1998/Math/MathML"> ϵ = 1 0 − 8 \epsilon = 10^{-8} </math>ϵ=10−8 是为防止 <math xmlns="http://www.w3.org/1998/Math/MathML"> log ⁡ ( 0 ) \log(0) </math>log(0) 数值溢出。 代码实现如下:

python 复制代码
def cross_entropy(pred, label, mask):
    logp = -np.log(pred[np.arange(len(pred)), label] + 1e-8)
    return logp[mask].mean()

pred[np.arange(34), label] 是花式索引:取出每个节点真实类别对应的预测概率。 取负对数后,只在 mask 选中的节点上求平均。因为这里 mask 只选了节点0(类别0)和节点33(类别1),所以损失就是这两个节点预测负对数的平均值。

5.2、梯度下降:手写数值微分(原理演示版,运行较慢)

真实框架用自动反向传播,我们为了展示原理,用 中心差分 近似梯度:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∂ L ∂ w ≈ L ( w + ϵ ) − L ( w − ϵ ) 2 ϵ \frac{\partial L}{\partial w} \approx \frac{L(w + \epsilon) - L(w - \epsilon)}{2\epsilon} </math>∂w∂L≈2ϵL(w+ϵ)−L(w−ϵ)

对 W1 和 W2 中的每一个元素,都做一次微小扰动,重算损失,得到偏导数。

python 复制代码
lr = 0.1
eps = 1e-5

for epoch in range(5000):
    # 前向传播(同上)
    H = np.maximum(0, A_norm @ X @ W1)
    out = A_norm @ H @ W2
    pred = softmax(out)
    loss = cross_entropy(pred, labels, train_mask)
    
    # 数值梯度 for W2
    grad_W2 = np.zeros_like(W2)
    for i in range(W2.shape[0]):
        for j in range(W2.shape[1]):
            W2[i,j] += eps
            H_pos = np.maximum(0, A_norm @ X @ W1)
            loss_pos = cross_entropy(softmax(A_norm @ H_pos @ W2), labels, train_mask)
            W2[i,j] -= 2*eps
            H_neg = np.maximum(0, A_norm @ X @ W1)
            loss_neg = cross_entropy(softmax(A_norm @ H_neg @ W2), labels, train_mask)
            W2[i,j] += eps   # 恢复
            grad_W2[i,j] = (loss_pos - loss_neg) / (2*eps)
    
    # 数值梯度 for W1(类似,省略)
    # ...
    
    # 梯度下降更新
    W1 -= lr * grad_W1
    W2 -= lr * grad_W2

注意 :因为 hidden_dim 很小,参数不多,双重循环还能忍。真实场景务必使用PyTorch的自动求导。

5.3、训练过程观察

对于本文这个半监督节点分类任务,训练过程中主要关注的就是LossTest Acc 这两个指标。但它们的作用不同,不能互相替代。

指标 看的是什么 作用 局限
Loss 模型在已知标签节点上的预测"自信程度" 模型是否在"认真学习"标注信息 只反映标注节点,可能过拟合
Test Acc 模型在未知标签节点上的分类正确率 标签信息是否成功"传播"到全图 需要真实标签才能计算

典型组合解读如下表所示:

Loss Test Acc 状态判断
高 (~0.7) 低 (~50%) ❌ 未收敛,模型在瞎猜
高 (>0.3) 高 (>90%) 🔴 异常,检查代码(可能是 Test Acc 计算 bug)
低 (<0.01) 低 (<60%) 🔴 过拟合到训练节点,没学会传播
低 (<0.01) 高 (>95%) ✅ 理想状态,模型既自信又准确
低 (<0.01) 中 (~80%) 🔴 本文情况,表达能力瓶颈 (hidden_dim=2)

初始损失约 0.693,对应 -log(0.5),即模型瞎猜。随着训练损失迅速下降,最终可能降到 0.05 以下。 上图是损失下降曲线(hidden_dim = 2,lr = 0.1),可以看到初始值约0.7左右,前500轮陡峭下降,之后趋缓,接近0。

上图是训练动态双轴图,可以看到前500轮震荡剧烈,准确率上下跳动,这是因为数值梯度不稳定和早期W随机初始化导致预测波动,500轮之后,从约0.94直接跳到0.8左右,之后完全水平不变,这并非训练停滞,而是hidden_dim = 2的表达能力上限。后文实验表明,当hidden_dim增至3或5时,准确率可达96.88%。二维嵌入为了可视化牺牲了部分精度,这是可预期的权衡。

六、结果可视化:二维嵌入与分类边界

训练完后,我们取出第一层的输出 H_final(形状 (34,2)),每个节点就是一个二维点,根据最终预测类别染色。

python 复制代码
H_final = np.maximum(0, A_norm @ X @ W1)
pred_final = np.argmax(softmax(A_norm @ H_final @ W2), axis=1)

import matplotlib.pyplot as plt
plt.figure(figsize=(8,6))
for i in range(34):
    color = 'red' if pred_final[i] == 0 else 'blue'
    plt.scatter(H_final[i,0], H_final[i,1], color=color, s=100)
    plt.text(H_final[i,0]+0.02, H_final[i,1]+0.02, str(i), fontsize=9)
plt.title("2D embedding learned by 2-layer GCN")
plt.show()

你会看到红蓝两团明显分开,即使我们只标注了节点0和节点33,标签信号也沿着边传播到了全图。这就是图卷积的半监督魔力。 如下图所示:

七、完整代码展示

python 复制代码
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt

# ============ 1. 造数据:Zachary空手道俱乐部图 ============
G = nx.karate_club_graph()
A = nx.to_numpy_array(G)                     # 邻接矩阵
n_nodes = A.shape[0]

# 节点特征:简单用 one-hot 度特征(每个节点是一个18维向量,独热它的度)
degrees = np.array([d for _, d in G.degree()], dtype=int)
max_deg = degrees.max()
X = np.eye(max_deg + 1)[degrees]             # (34, max_deg+1) 独热编码

# 标签:俱乐部实际分裂成两个社区(0 和 1)
labels = np.array([G.nodes[i]['club'] for i in range(n_nodes)])
labels = (labels == 'Mr. Hi').astype(int)    # 转成 0/1

# 训练/测试划分:只用 2个节点训练(演示半监督学习)
train_mask = np.zeros(n_nodes, dtype=bool)
train_mask[0] = True                         
train_mask[33] = True
test_mask=~train_mask

# ============ 2. 构建对称归一化邻接矩阵 ============
A_tilde = A + np.eye(n_nodes)                # 加自环
D_tilde = np.diag(np.sum(A_tilde, axis=1))   # 度矩阵
D_inv_sqrt = np.linalg.inv(np.sqrt(D_tilde)) # D^{-1/2}
A_norm = D_inv_sqrt @ A_tilde @ D_inv_sqrt   # 对称归一化

# ============ 3. 定义单层 GCN + 训练 ============
np.random.seed(42)
input_dim = X.shape[1]
hidden_dim = 2    # 降维到2维,方便可视化
output_dim = 2    # 二分类

W1 = np.random.randn(input_dim, hidden_dim) * 0.01
W2 = np.random.randn(hidden_dim, output_dim) * 0.01

def softmax(x):
    e = np.exp(x - np.max(x, axis=1, keepdims=True))
    return e / np.sum(e, axis=1, keepdims=True)

def cross_entropy(pred, label, mask):
    # 只计算被 mask 选中的节点
    logp = -np.log(pred[np.arange(len(pred)), label] + 1e-8)
    return logp[mask].mean()

lr = 0.1
for epoch in range(5000):
    # 前向传播:两层 GCN(中间 ReLU,最后 softmax)
    H = np.maximum(0, A_norm @ X @ W1)       # ReLU
    out = A_norm @ H @ W2
    pred = softmax(out)

    loss = cross_entropy(pred, labels, train_mask)
    if epoch % 500 == 0:
        # 测试准确率
        pred_class = np.argmax(pred, axis=1)
        acc = (pred_class[test_mask] == labels[test_mask]).mean()
        print(f"Epoch {epoch:3d} | Loss: {loss:.4f} | Test Acc: {acc:.4f}")

    # 手动计算梯度(简化版:对 W2 和 W1 求导并更新)
    # 为了简洁,这里使用数值梯度演示(生产请用 autograd 框架)
    # --- 数值梯度 for W2 ---
    eps = 1e-5
    grad_W2 = np.zeros_like(W2)
    for i in range(W2.shape[0]):
        for j in range(W2.shape[1]):
            W2[i,j] += eps
            H_test = np.maximum(0, A_norm @ X @ W1)
            out_test = A_norm @ H_test @ W2
            loss_pos = cross_entropy(softmax(out_test), labels, train_mask)
            W2[i,j] -= 2*eps
            H_test = np.maximum(0, A_norm @ X @ W1)
            out_test = A_norm @ H_test @ W2
            loss_neg = cross_entropy(softmax(out_test), labels, train_mask)
            W2[i,j] += eps   # 恢复
            grad_W2[i,j] = (loss_pos - loss_neg) / (2*eps)
    # --- 数值梯度 for W1 (参数较少,完整循环仍可接受) ---
    grad_W1 = np.zeros_like(W1)
    for i in range(W1.shape[0]):
        for j in range(W1.shape[1]):
            W1[i,j] += eps
            H_test = np.maximum(0, A_norm @ X @ W1)
            out_test = A_norm @ H_test @ W2
            loss_pos = cross_entropy(softmax(out_test), labels, train_mask)
            W1[i,j] -= 2*eps
            H_test = np.maximum(0, A_norm @ X @ W1)
            out_test = A_norm @ H_test @ W2
            loss_neg = cross_entropy(softmax(out_test), labels, train_mask)
            W1[i,j] += eps
            grad_W1[i,j] = (loss_pos - loss_neg) / (2*eps)

    W1 -= lr * grad_W1
    W2 -= lr * grad_W2

# ============ 4. 最终结果可视化 ============
H_final = np.maximum(0, A_norm @ X @ W1)   # 获取2维嵌入
pred_final = np.argmax(softmax(A_norm @ H_final @ W2), axis=1)

plt.figure(figsize=(8,6))
for i in range(n_nodes):
    color = 'red' if pred_final[i] == 0 else 'blue'
    plt.scatter(H_final[i, 0], H_final[i, 1], color=color, s=100)
    plt.text(H_final[i,0]+0.02, H_final[i,1]+0.02, str(i), fontsize=9)
plt.title("2D embedding learned by 2-layer GCN")
plt.show()

八、实验与调参小计

8.1、为什么有时所有节点都被预测为同一类?

只标节点0(标签0)时,模型可能坍缩:把所有节点都预测为1。因为损失只惩罚节点0的错误,而模型发现把所有节点全押1比费力学习边界更容易?实际上,如果初始化让节点0也倾向于1,梯度可能无力回天。 上图是一个34维向量,代表34个节点的实际标签。 当只标节点0时,34个节点的预测结果如上图所示。可以看到,模型坍缩,所有节点都预测为1,这显然与实际不符。 解决办法 :每类至少标注一个节点。加上 train_mask[33]=True(节点33标签为1),训练立刻稳定,准确率近乎100%。 预测结果如下图所示: 可见,在标注节点0和节点33后,预测结果很好的符合实际。。

8.2、hidden_dim 的选择

默认设为2是为了把嵌入直接画在二维图上。但如果你纯粹追求分类精度,可以尝试不同维度(2~7)。在本次实验中(epoch=5000,lr=0.1),hidden_dim=3 和hidden_dim=5 往往达到最高准确率------容量与过拟合的最佳平衡点。结果如下表所示:

hidden_dim 准确率
2 81.25%
3 96.88%
4 93.75%
5 96.88%
6 81.25%
7 84.38%

8.3、学习率与数值梯度

lr=0.1 在小参数下可行,但若增大 hidden_dim,参数变多,数值梯度的误差也会累积,可能需要调小学习率或增加迭代轮数。这也是为什么生产环境一定要用自动微分+优化器。

九、从NumPy到PyTorch Geometric:代码对比

学完NumPy版,你可能想知道PyG怎么写?以下是等价实现,供对比:

python 复制代码
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
from torch_geometric.datasets import KarateClub

# 加载数据(PyG自带空手道图)
dataset = KarateClub()
data = dataset[0]  # 包含 x, edge_index, y, train_mask

class GCN(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, out_channels)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index).relu()
        x = self.conv2(x, edge_index)
        return x

# 正确传递参数:全部使用位置参数或全部使用关键字参数
model = GCN(dataset.num_features, 2, dataset.num_classes)  # hidden_channels=2
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

# 训练(自动求导,无需手动梯度)
for epoch in range(200):
    optimizer.zero_grad()
    out = model(data.x, data.edge_index)
    loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask])
    loss.backward()  # 自动反向传播!
    optimizer.step()

    # 可选:打印训练损失
    if epoch % 20 == 0:
        print(f'Epoch {epoch:03d}, Loss: {loss.item():.4f}')

对比要点:

  • PyG的 GCNConv 自动完成了 <math xmlns="http://www.w3.org/1998/Math/MathML"> D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} </math>D~−1/2A~D~−1/2 的构建和应用
  • loss.backward() 替代了数值梯度的双重循环,速度提升数百倍
  • 相同的数学原理,只是框架帮你做了脏活累活

十、总结:一张图看清GCN全貌

回顾整个流程,一个两层GCN不过是:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> Y ~ = softmax ( A ~ ReLU ( A ~ X W ( 0 ) )   W ( 1 ) ) \tilde{Y} = \text{softmax}\left( \tilde{A} \text{ReLU}(\tilde{A} X W^{(0)}) \, W^{(1)} \right) </math>Y~=softmax(A~ReLU(A~XW(0))W(1))

  • 输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> X X </math>X:节点特征(哪怕只是度热性)
  • 核心 <math xmlns="http://www.w3.org/1998/Math/MathML"> A norm A_{\text{norm}} </math>Anorm:对称归一化邻接矩阵,实现加权邻居聚合
  • 可学习参数 <math xmlns="http://www.w3.org/1998/Math/MathML"> W W </math>W:线性变换,提取高级特征
  • 激活 ReLU:非线性
  • 输出 softmax:概率
  • 训练:半监督交叉熵 + 梯度下降

所有组件都可用NumPy矩阵乘法清晰表达。本文代码虽然简单,却涵盖了GCN的核心思想。 希望这篇手撸教程能让你对图神经网络不再畏惧,反而觉得它们优雅又有趣。如果你用GCN做了什么好玩的项目,欢迎在评论区交流! 本文首次发布在CSDN平台。

参考文献

1\] Kipf T N, Welling M. Semi-Supervised Classification with Graph Convolutional Networks. *ICLR* , 2017. [arxiv.org/abs/1609.02...](https://link.juejin.cn?target=https%3A%2F%2Farxiv.org%2Fabs%2F1609.02907 "https://arxiv.org/abs/1609.02907") \[2\] Zachary W W. An Information Flow Model for Conflict and Fission in Small Groups. *Journal of Anthropological Research*, 1977, 33(4): 452-473. \[3\] Hagberg A, Schult D, Swart P. Exploring network structure, dynamics, and function using NetworkX. *Proc. of the 7th Python in Science Conference (SciPy)* , 2008. [networkx.org](https://link.juejin.cn?target=https%3A%2F%2Fnetworkx.org "https://networkx.org")

相关推荐
Larcher2 小时前
🔥 告别抓瞎:用 Claude Code (cc) 优雅接手与维护已有项目
javascript·机器学习·前端框架
大模型最新论文速读4 小时前
PreFT:只在 prefill 时使用 LoRA,推理速度翻倍效果不降
论文阅读·人工智能·深度学习·机器学习·自然语言处理
AI算法沐枫5 小时前
大模型 | 大模型之机器学习基本理论
人工智能·python·神经网络·学习·算法·机器学习·计算机视觉
larance5 小时前
[菜鸟教程] 机器学习教程第六课-机器学习基础术语
人工智能·机器学习
cxr8285 小时前
数据驱动的AI逆向材料设计:体系、方法与突破路径
人工智能·机器学习·智能体·逆向合成·材料设计合成·蜂群理论
Project_Observer6 小时前
使用Zoho Projects AI自动项目管理
大数据·数据库·人工智能·深度学习·机器学习·深度优先
前端若水6 小时前
【无标题】
java·人工智能·python·机器学习
吃好睡好便好7 小时前
在Matlab中绘制阶梯图
开发语言·人工智能·学习·算法·机器学习·matlab
郑同学zxc8 小时前
机器学习20-RNN
人工智能·rnn·机器学习