第1章では、グラフデータの基本構造とPyTorch Geometric(PyG)の使い方を学びました。この章では一歩踏み込み、グラフニューラルネットワーク(GNN)の中核をなすメッセージパッシング(Message Passing)の仕組みと、その代表例であるグラフ畳み込みネットワーク(Graph Convolutional Network、GCN)の数理的背景を学びます。そのうえで、PyGのGCNConvレイヤーを使い、実際の引用ネットワークデータセットであるCoraでノード分類タスクを実装し、学習・評価・過学習対策までを一通り体験します。
学習目標
- ✅ メッセージパッシングの仕組みを説明できる
- ✅ グラフ畳み込みの数理的背景を理解する
- ✅ PyTorch GeometricのGCNConvレイヤーを使いこなせる
- ✅ Coraデータセットでノード分類タスクを実装できる
- ✅ 適切な評価指標でモデルを評価できる
- ✅ 過学習対策を実装できる
1. メッセージパッシングの仕組み
メッセージパッシング(Message Passing)とは、グラフ上の各ノードが隣接ノードから情報(メッセージ)を受け取り、それらを集約して自分自身の特徴量を更新していく処理の枠組みです。ほとんどのグラフニューラルネットワーク(GCN、GraphSAGE、GAT等)はこのメッセージパッシングの枠組みの上に構築されています。
メッセージパッシングは、次の3つのステップから構成されます。
- Message(メッセージ生成): 各エッジについて、送信元ノードの特徴量(と必要に応じてエッジ特徴量)から「メッセージ」を計算する
- Aggregate(集約): 各ノードが受け取った複数のメッセージを、総和・平均・最大値などの操作で1つのベクトルにまとめる
- Update(更新): 集約されたメッセージと、ノード自身の現在の特徴量を使って、新しい特徴量を計算する
この枠組みを数式で表すと、層 \(k\) におけるノード \(i\) の更新は次のようになります。
$$\mathbf{m}_{j \to i}^{(k)} = \text{MESSAGE}^{(k)}\left(\mathbf{x}_i^{(k-1)}, \mathbf{x}_j^{(k-1)}\right)$$
$$\mathbf{a}_i^{(k)} = \text{AGGREGATE}^{(k)}\left(\left\{\mathbf{m}_{j \to i}^{(k)} \mid j \in \mathcal{N}(i)\right\}\right)$$
$$\mathbf{x}_i^{(k)} = \text{UPDATE}^{(k)}\left(\mathbf{x}_i^{(k-1)}, \mathbf{a}_i^{(k)}\right)$$
ここで \(\mathcal{N}(i)\) はノード \(i\) の隣接ノード集合です。この3ステップを何度も繰り返す(層を重ねる)ことで、各ノードはより遠くのノードの情報まで間接的に取り込めるようになります。
手作業でメッセージパッシングを体験する
まずはtorch_geometricのレイヤーを使わずに、素のPyTorchでメッセージパッシングの3ステップを実装してみましょう。
import torch
# 4ノードのグラフ(有向エッジ: 0→1, 1→2, 0→2, 2→3, 1→3)
edge_index = torch.tensor([
[0, 1, 0, 2, 1], # 送信元(source)
[1, 2, 2, 3, 3] # 送信先(destination)
], dtype=torch.long)
# 各ノードの初期特徴量(2次元)
x = torch.tensor([
[1.0, 0.0],
[0.0, 1.0],
[1.0, 1.0],
[0.5, 0.5]
], dtype=torch.float)
num_nodes = x.size(0)
src, dst = edge_index[0], edge_index[1]
# Step 1: Message(ここでは送信元ノードの特徴量をそのままメッセージとする)
messages = x[src] # 形状: [num_edges, num_features]
# Step 2: Aggregate(各終点ノードごとにメッセージを総和で集約)
aggregated = torch.zeros(num_nodes, x.size(1))
aggregated.index_add_(0, dst, messages)
# Step 3: Update(今回は単純化のため、集約結果をそのまま新しい特徴量とする)
x_new = aggregated
print("集約前の特徴量:\n", x)
print("\n集約後の特徴量(隣接ノードの情報を受け取った状態):\n", x_new)
このコードでは、index_add_を使ってエッジの終点ノードごとにメッセージを合計しています。実際のGCNでは、Step 1のメッセージ生成に線形変換と正規化係数を、Step 3の更新に活性化関数を組み込みます。
MessagePassing基底クラスによる実装
PyTorch Geometricは、この3ステップの枠組みをtorch_geometric.nn.MessagePassingという基底クラスとして提供しています。message()と(必要なら)update()メソッドを定義するだけで、集約処理はpropagate()が自動的に行ってくれます。
import torch
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree
class SimpleGCNLayer(MessagePassing):
def __init__(self, in_channels, out_channels):
super().__init__(aggr='add') # 集約方法: 総和
self.lin = torch.nn.Linear(in_channels, out_channels, bias=False)
def forward(self, x, edge_index):
# 自己ループを追加(自分自身の情報も集約に含める)
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
# 線形変換
x = self.lin(x)
# 正規化係数の計算(次数の平方根の逆数)
row, col = edge_index
deg = degree(col, x.size(0), dtype=x.dtype)
deg_inv_sqrt = deg.pow(-0.5)
deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
# propagate()がmessage → aggregate → updateの順に自動的に呼び出す
return self.propagate(edge_index, x=x, norm=norm)
def message(self, x_j, norm):
# x_j: 送信元ノード(隣接ノード)の特徴量。PyGが自動的に集めてくれる
return norm.view(-1, 1) * x_j
# 動作確認
edge_index = torch.tensor([[0, 1, 1, 2],
[1, 0, 2, 1]], dtype=torch.long)
x = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=torch.float)
layer = SimpleGCNLayer(in_channels=2, out_channels=4)
out = layer(x, edge_index)
print("出力形状:", out.shape)
print("出力:\n", out)
💡 x_iとx_jの命名規則
MessagePassingのmessage()メソッドでは、x_iが集約先(自分自身)のノード特徴量、x_jが集約元(隣接ノード)の特徴量を指す変数名として自動的に解決されます。この命名規則を知っておくと、PyGの公式実装や他の人が書いたカスタムレイヤーを読み解きやすくなります。
このコードは、後述するGCNConvレイヤーが内部で行っている処理をほぼそのまま再現したものです。実際のGCNConvはさらにキャッシュや数値安定性の工夫が加えられていますが、基本原理は同じです。
2. グラフ畳み込みの数理的背景
GCNは、Kipf & Welling(2017)が提案した手法で、グラフ信号処理におけるスペクトルグラフ畳み込み(Spectral Graph Convolution)を1次近似することで、計算コストの低い実用的なレイヤーとして定式化されています。ここでは、実装上重要な「対称正規化」の考え方に絞って解説します。
自己ループと正規化隣接行列
ノード数を \(N\) とし、グラフの隣接行列を \(A \in \mathbb{R}^{N \times N}\) とします。GCNでは、各ノード自身の特徴量も集約に含めるため、単位行列 \(I_N\) を加えた自己ループ付き隣接行列(Adjacency Matrix with Self-loops)を使います。
$$\tilde{A} = A + I_N$$
次に、次数行列 \(\tilde{D}\) を対角行列として定義します。
$$\tilde{D}_{ii} = \sum_{j} \tilde{A}_{ij}$$
そして、次数の大きいノードの影響が過大にならないよう、対称正規化(Symmetric Normalization)を施します。
$$\hat{A} = \tilde{D}^{-\frac{1}{2}} \tilde{A} \tilde{D}^{-\frac{1}{2}}$$
この \(\hat{A}\) を使うと、GCNの層ごとの伝播規則は次のように書けます。
$$H^{(l+1)} = \sigma\left(\hat{A} H^{(l)} W^{(l)}\right)$$
ここで:
- \(H^{(l)} \in \mathbb{R}^{N \times d_l}\): 層 \(l\) におけるすべてのノードの特徴量行列
- \(W^{(l)}\): 層 \(l\) の学習可能な重み行列
- \(\sigma\): 活性化関数(ReLU等)
この式の \(\hat{A} H^{(l)}\) の部分が、まさに前節のメッセージパッシングにおける「Aggregate」に対応します。対称正規化を使わない単純な総和では、次数の大きいノードほど値が大きくなりすぎて学習が不安定になりますが、\(\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2}\) による正規化によってこれが緩和されます。
import numpy as np
# 4ノードの無向グラフ(自己ループなし)
A = np.array([
[0, 1, 1, 0],
[1, 0, 1, 1],
[1, 1, 0, 1],
[0, 1, 1, 0]
], dtype=float)
# 自己ループを追加: A_tilde = A + I
I = np.eye(4)
A_tilde = A + I
# 次数行列 D_tilde
D_tilde = np.diag(A_tilde.sum(axis=1))
# 対称正規化: D_tilde^(-1/2) A_tilde D_tilde^(-1/2)
D_inv_sqrt = np.diag(1.0 / np.sqrt(np.diag(D_tilde)))
A_norm = D_inv_sqrt @ A_tilde @ D_inv_sqrt
print("自己ループ付き隣接行列 A_tilde:\n", A_tilde)
print("\n次数行列 D_tilde:\n", D_tilde)
print("\n正規化された隣接行列 A_norm:\n", np.round(A_norm, 3))
出力されるA_normの各要素は、GCNConvが内部で使う正規化係数と同じ値になります。次節で見るGCNConvレイヤーは、このスパースな正規化計算を効率的に(隣接行列を明示的に作らずエッジインデックスのまま)実行しています。
💡 層を重ねると何が起きるか(受容野)
1層のGCNでは各ノードは1-hop(直接の隣接ノード)の情報しか得られませんが、層を重ねるごとに間接的に到達できるノードの範囲(受容野(Receptive Field))が広がります。ただし層を重ねすぎると、すべてのノードの特徴量が似通ってしまう過平滑化(Over-smoothing)という問題が起きやすくなるため、一般的なGCNは2〜4層程度に留めることが多くなっています。
0-hop] --> L1[GCN層1の出力
1-hop近傍を集約] L1 --> L2[GCN層2の出力
2-hop近傍まで伝播] L2 --> L3[GCN層3の出力
3-hop近傍まで伝播]
3. GCNConvレイヤーの詳細
PyTorch Geometricは、前節の数式をそのまま実装したtorch_geometric.nn.GCNConvレイヤーを提供しています。自己ループの追加、対称正規化、線形変換をすべて内部で処理してくれるため、私たちはレイヤーを積み重ねるだけでGCNを構築できます。
主なパラメータ
| パラメータ | デフォルト値 | 説明 |
|---|---|---|
in_channels |
必須 | 入力特徴量の次元数 |
out_channels |
必須 | 出力特徴量の次元数 |
improved |
False |
Trueにすると自己ループの重みを2倍にする(\(\tilde{A} = A + 2I\)) |
add_self_loops |
True |
自己ループを自動的に追加するかどうか |
normalize |
True |
対称正規化を適用するかどうか |
cached |
False |
正規化係数をキャッシュするかどうか(同一グラフを繰り返し使う場合に高速化できる) |
bias |
True |
バイアス項を追加するかどうか |
import torch
from torch_geometric.nn import GCNConv
conv = GCNConv(in_channels=16, out_channels=8)
print("重み行列の形状:", conv.lin.weight.shape)
print("バイアスの形状:", conv.bias.shape)
print("学習可能パラメータ数:", sum(p.numel() for p in conv.parameters()))
# 簡単なグラフでの動作確認
x = torch.randn(5, 16) # 5ノード、16次元特徴量
edge_index = torch.tensor([[0, 1, 2, 3, 4, 0],
[1, 2, 3, 4, 0, 2]], dtype=torch.long)
out = conv(x, edge_index)
print("\n出力形状:", out.shape) # [5, 8]
improvedパラメータの効果を確認する
improved=Trueにすると、自己ループの重みを2倍にした \(\tilde{A} = A + 2I\) が使われます。これは自分自身の情報をより強く保持したい場合に有効な設定です。
import torch
from torch_geometric.nn import GCNConv
x = torch.tensor([[1.0], [2.0], [3.0]], dtype=torch.float)
edge_index = torch.tensor([[0, 1, 1, 2],
[1, 0, 2, 1]], dtype=torch.long)
torch.manual_seed(42)
conv_normal = GCNConv(1, 1, improved=False, add_self_loops=True)
torch.manual_seed(42)
conv_improved = GCNConv(1, 1, improved=True, add_self_loops=True)
out_normal = conv_normal(x, edge_index)
out_improved = conv_improved(x, edge_index)
print("通常のGCNConv(improved=False)の出力:\n", out_normal)
print("\nimproved=True の出力(自己ループの重みを2倍にした場合):\n", out_improved)
⚠️ cached=Trueの注意点
cached=Trueは、Coraのように学習中グラフ構造が変化しない(トランスダクティブな)設定では便利ですが、ミニバッチ処理で毎回異なるサブグラフを渡すような場合には使うべきではありません。古いグラフに対する正規化係数がキャッシュされ続け、誤った結果を招くためです。
4. ノード分類タスクの実装(Coraデータセット)
ここからは、第1章でも紹介したCoraデータセットを使い、実際にGCNでノード分類(Node Classification)を実装します。Coraは2,708本の論文をノード、引用関係をエッジとする引用ネットワークで、各論文は1,433次元のBag-of-Words特徴量を持ち、7つの研究分野のいずれかに分類されます。
3層GCNモデルの定義
第1章では2層のシンプルなGCNを実装しましたが、ここでは中間層を2つ持つ3層のGCNを実装し、各層の間にDropout(後述)を挿入します。最終層は活性化関数を通さず、生のロジット(Logit)をそのまま出力する点に注意してください。これは、後述するCrossEntropyLossが内部でソフトマックス相当の処理を行うため、モデル側で確率化する必要がないからです。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.datasets import Planetoid
from torch_geometric.nn import GCNConv
# Coraデータセットの読み込み
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
class GCN(nn.Module):
def __init__(self, num_features, hidden_channels, num_classes, dropout=0.5):
super().__init__()
self.conv1 = GCNConv(num_features, hidden_channels[0])
self.conv2 = GCNConv(hidden_channels[0], hidden_channels[1])
self.conv3 = GCNConv(hidden_channels[1], num_classes)
self.dropout = dropout
def forward(self, x, edge_index):
x = self.conv1(x, edge_index)
x = F.relu(x)
x = F.dropout(x, p=self.dropout, training=self.training)
x = self.conv2(x, edge_index)
x = F.relu(x)
x = F.dropout(x, p=self.dropout, training=self.training)
x = self.conv3(x, edge_index) # 最終層はロジットをそのまま出力
return x
model = GCN(num_features=dataset.num_features,
hidden_channels=[32, 16],
num_classes=dataset.num_classes)
print(model)
print(f"\n学習可能パラメータ数: {sum(p.numel() for p in model.parameters()):,}")
このモデルは、1,433次元 → 32次元 → 16次元 → 7クラスという流れで特徴量を段階的に圧縮していきます。層を重ねるごとに、前節で説明した受容野が1-hop、2-hop、3-hopと広がっていきます。
5. 学習ループと評価指標
Adam最適化とCrossEntropyLossによる学習
学習にはAdamオプティマイザとtorch.nn.CrossEntropyLossを使います。CrossEntropyLossは内部でソフトマックスと負の対数尤度損失を組み合わせて計算するため、モデルの出力は正規化前のロジットのままで問題ありません。
import torch
import torch.nn as nn
from torch_geometric.datasets import Planetoid
# データとモデルの準備(前項で定義したGCNクラスを使用します)
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = GCN(num_features=dataset.num_features,
hidden_channels=[32, 16],
num_classes=dataset.num_classes,
dropout=0.5).to(device)
data = data.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
def train():
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
return loss.item()
@torch.no_grad()
def evaluate(mask):
model.eval()
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
correct = (pred[mask] == data.y[mask]).sum().item()
return correct / mask.sum().item()
for epoch in range(1, 201):
loss = train()
if epoch % 20 == 0:
train_acc = evaluate(data.train_mask)
val_acc = evaluate(data.val_mask)
print(f"Epoch {epoch:03d} | Loss: {loss:.4f} | Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f}")
test_acc = evaluate(data.test_mask)
print(f"\nTest Accuracy: {test_acc:.4f}")
出力例:
Epoch 020 | Loss: 1.5893 | Train Acc: 0.7357 | Val Acc: 0.6660
Epoch 040 | Loss: 0.9142 | Train Acc: 0.9214 | Val Acc: 0.7580
Epoch 060 | Loss: 0.5271 | Train Acc: 0.9714 | Val Acc: 0.7740
Epoch 080 | Loss: 0.3520 | Train Acc: 0.9857 | Val Acc: 0.7780
Epoch 100 | Loss: 0.2647 | Train Acc: 0.9929 | Val Acc: 0.7820
Epoch 120 | Loss: 0.2103 | Train Acc: 1.0000 | Val Acc: 0.7800
Epoch 140 | Loss: 0.1782 | Train Acc: 1.0000 | Val Acc: 0.7760
Epoch 160 | Loss: 0.1549 | Train Acc: 1.0000 | Val Acc: 0.7740
Epoch 180 | Loss: 0.1401 | Train Acc: 1.0000 | Val Acc: 0.7720
Epoch 200 | Loss: 0.1298 | Train Acc: 1.0000 | Val Acc: 0.7700
Test Accuracy: 0.7930
訓練精度(Train Acc)が100%に達している一方で、検証精度(Val Acc)はエポック100付近をピークに緩やかに下がり始めています。これは次節で扱う過学習(Overfitting)の典型的な兆候です。
正解率だけでは見えないもの
正解率(Accuracy)はモデル評価の基本指標ですが、クラスごとの性能差を隠してしまうことがあります。特にクラス間でサンプル数に偏りがある場合、少数派クラスをほとんど正解できていなくても全体の正解率は高く見えることがあります。そこで、クラスごとの性能を評価できる適合率(Precision)、再現率(Recall)、F1スコア(F1 Score)も併せて確認します。
- 適合率(Precision): そのクラスと予測したもののうち、実際に正しかった割合
- 再現率(Recall): 実際にそのクラスであるもののうち、正しく予測できた割合
- F1スコア(F1 Score): 適合率と再現率の調和平均
from sklearn.metrics import classification_report, confusion_matrix
@torch.no_grad()
def detailed_evaluation(mask, mask_name):
model.eval()
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
y_true = data.y[mask].cpu().numpy()
y_pred = pred[mask].cpu().numpy()
print(f"=== {mask_name}の評価指標 ===")
print(classification_report(y_true, y_pred, digits=3, zero_division=0))
print("混同行列(Confusion Matrix):\n", confusion_matrix(y_true, y_pred))
detailed_evaluation(data.test_mask, "テストデータ")
💡 マクロ平均とマイクロ平均
classification_reportが出力するmacro avgは各クラスの指標を単純平均したもので、少数派クラスの性能低下も等しく反映されます。一方weighted avgはクラスごとのサンプル数で重み付けした平均です。クラス不均衡なデータセットでモデルを比較するときは、正解率だけでなくmacro avgのF1スコアも確認する習慣をつけましょう。
6. 過学習対策(Dropout、正則化)
前節の学習曲線で見たように、訓練精度が100%に達しても検証精度が頭打ちになる(あるいは低下する)現象は過学習(Overfitting)と呼ばれます。Coraの標準的な分割では、訓練に使えるラベル付きノードはわずか140個(全体の約5%)しかないため、GCNは比較的過学習しやすい設定だといえます。ここでは代表的な2つの対策を確認します。
Dropoutと重み減衰(weight_decay)
Dropoutは、学習時にランダムに一部のユニットの出力を0にすることで、モデルが特定のノードや特徴量の組み合わせに過度に依存するのを防ぐ手法です。すでにこの章のGCNモデルにもF.dropoutとして組み込んでいます。
もう1つの代表的な対策が重み減衰(Weight Decay)、いわゆるL2正則化(L2 Regularization)です。損失関数に重みの二乗和を加えることで、大きすぎる重みにペナルティを与え、モデルを滑らかに保ちます。PyTorchではAdamオプティマイザのweight_decay引数として簡単に指定できます。
これら2つの設定がある場合とない場合で、訓練精度とテスト精度の差(汎化ギャップ(Generalization Gap))がどう変わるかを実験してみましょう。
import torch
import torch.nn as nn
from torch_geometric.datasets import Planetoid
def run_experiment(dropout_rate, weight_decay, epochs=200, seed=0):
torch.manual_seed(seed)
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
# GCNクラスは「4. ノード分類タスクの実装」で定義したものを使用します
model = GCN(num_features=dataset.num_features,
hidden_channels=[32, 16],
num_classes=dataset.num_classes,
dropout=dropout_rate)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=weight_decay)
criterion = nn.CrossEntropyLoss()
for epoch in range(epochs):
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
train_acc = (pred[data.train_mask] == data.y[data.train_mask]).float().mean().item()
test_acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean().item()
return train_acc, test_acc
# ケース1: 正則化なし(過学習しやすい設定)
train_acc_no_reg, test_acc_no_reg = run_experiment(dropout_rate=0.0, weight_decay=0.0)
# ケース2: Dropout + weight_decayあり
train_acc_reg, test_acc_reg = run_experiment(dropout_rate=0.5, weight_decay=5e-4)
print("設定 | Train Acc | Test Acc | 汎化ギャップ")
print("-" * 62)
print(f"正則化なし | {train_acc_no_reg:.4f} | {test_acc_no_reg:.4f} | {train_acc_no_reg - test_acc_no_reg:.4f}")
print(f"Dropout + weight_decay | {train_acc_reg:.4f} | {test_acc_reg:.4f} | {train_acc_reg - test_acc_reg:.4f}")
出力例:
設定 | Train Acc | Test Acc | 汎化ギャップ
--------------------------------------------------------------
正則化なし | 1.0000 | 0.7460 | 0.2540
Dropout + weight_decay | 1.0000 | 0.7930 | 0.2070
正則化なしの設定では訓練精度が100%になる一方でテスト精度が伸び悩み、汎化ギャップが大きくなる傾向が確認できます。DropoutとL2正則化を併用すると、このギャップがある程度縮小します。
早期終了(Early Stopping)
もう1つの実用的な対策が早期終了(Early Stopping)です。検証データに対する損失(Validation Loss)を毎エポック監視し、一定エポック数(Patience)連続で改善しなければ学習を打ち切り、それまでで最も検証損失が低かった時点のモデルを採用します。
import copy
import torch
import torch.nn as nn
from torch_geometric.datasets import Planetoid
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
model = GCN(num_features=dataset.num_features,
hidden_channels=[32, 16],
num_classes=dataset.num_classes,
dropout=0.5)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = nn.CrossEntropyLoss()
best_val_loss = float('inf')
patience = 20
patience_counter = 0
best_model_state = None
for epoch in range(1, 301):
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
out = model(data.x, data.edge_index)
val_loss = criterion(out[data.val_mask], data.y[data.val_mask]).item()
if val_loss < best_val_loss:
best_val_loss = val_loss
best_model_state = copy.deepcopy(model.state_dict())
patience_counter = 0
else:
patience_counter += 1
if patience_counter >= patience:
print(f"Epoch {epoch}: 検証損失が{patience}エポック改善しなかったため学習を停止します")
break
# 最も検証損失が低かった時点のモデルを復元
model.load_state_dict(best_model_state)
model.eval()
with torch.no_grad():
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
test_acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean().item()
print(f"Early Stoppingによる最終テスト精度: {test_acc:.4f}")
🎉 過学習対策の組み合わせ
Dropout、weight_decay(L2正則化)、Early Stoppingは、それぞれ異なる角度から過学習を抑制します。実務では、この3つを組み合わせて使うことが一般的です。どの手法がどれだけ効いているかを個別に確認しながらチューニングすることで、限られたラベル付きデータからでも汎化性能の高いモデルを構築できます。
演習問題
演習1:メッセージパッシングの3ステップとGCNの対応関係
メッセージパッシングの3ステップ(Message、Aggregate、Update)を自分の言葉で説明したうえで、この章の数式 \(H^{(l+1)} = \sigma\left(\hat{A} H^{(l)} W^{(l)}\right)\) の中で、それぞれのステップがどこに対応するかを述べてください。
解答を見る
Message: 各ノードの特徴量 \(H^{(l)}\) に重み行列 \(W^{(l)}\) を掛けて線形変換する部分(\(H^{(l)} W^{(l)}\))が、隣接ノードに送るメッセージの生成に相当します。
Aggregate: 正規化隣接行列 \(\hat{A}\) を掛ける部分(\(\hat{A} (H^{(l)} W^{(l)})\))が、各ノードが隣接ノード(および自分自身)からのメッセージを重み付き総和で集約する処理に相当します。
Update: 集約結果に活性化関数 \(\sigma\) を適用する部分が、集約されたメッセージから新しい特徴量を計算する更新処理に相当します。
演習2:合成データでの2層GCN実装
Coraデータセットを使わず、以下の条件を満たす合成データで2層のGCNを実装し、学習・評価してください。
- 5ノードの環状グラフ(無向、各ノードが両隣とエッジで結ばれる)
- 各ノードは8次元の特徴量を持つ
- 2クラス分類タスクとする
- CrossEntropyLossとAdamオプティマイザを使って100エポック学習する
解答を見る
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.data import Data
from torch_geometric.nn import GCNConv
# 5ノードの合成グラフ(無向、環状構造)
edge_index = torch.tensor([
[0, 1, 1, 2, 2, 3, 3, 4, 4, 0],
[1, 0, 2, 1, 3, 2, 4, 3, 0, 4]
], dtype=torch.long)
torch.manual_seed(0)
x = torch.randn(5, 8) # 5ノード、8次元特徴量
y = torch.tensor([0, 1, 0, 1, 0], dtype=torch.long) # 2クラス分類
data = Data(x=x, edge_index=edge_index, y=y)
class SmallGCN(nn.Module):
def __init__(self, in_channels, hidden_channels, num_classes):
super().__init__()
self.conv1 = GCNConv(in_channels, hidden_channels)
self.conv2 = GCNConv(hidden_channels, num_classes)
def forward(self, x, edge_index):
x = F.relu(self.conv1(x, edge_index))
x = F.dropout(x, p=0.3, training=self.training)
x = self.conv2(x, edge_index)
return x
model = SmallGCN(in_channels=8, hidden_channels=4, num_classes=2)
optimizer = torch.optim.Adam(model.parameters(), lr=0.05, weight_decay=1e-4)
criterion = nn.CrossEntropyLoss()
for epoch in range(100):
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out, data.y)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
pred = model(data.x, data.edge_index).argmax(dim=1)
acc = (pred == data.y).float().mean().item()
print(f"最終Loss: {loss.item():.4f}")
print(f"精度: {acc:.4f}")
ノード数が5と極端に少ないため、この例では訓練データそのものを評価にも使っています。実務では必ず訓練・検証・テストを分割してください。
演習3:Dropout率の比較実験
この章の「過学習対策」で定義したrun_experiment関数を使い、Dropout率を0.0、0.3、0.5、0.7の4パターンで試して、テスト精度がどう変化するか比較してください。weight_decayは5e-4に固定してよいものとします。傾向について、あなたの考察を1〜2文で書いてください。
解答を見る
dropout_rates = [0.0, 0.3, 0.5, 0.7]
results = {}
for rate in dropout_rates:
train_acc, test_acc = run_experiment(dropout_rate=rate, weight_decay=5e-4)
results[rate] = (train_acc, test_acc)
print(f"dropout={rate}: Train Acc={train_acc:.4f}, Test Acc={test_acc:.4f}")
考察例: Dropout率が0に近いと訓練精度は非常に高くなる一方でテスト精度は伸び悩みやすく、0.5前後で最も良いテスト精度が得られる傾向があります。Dropout率を0.7まで上げすぎると、学習に必要な情報まで失われ、訓練・テストの両方の精度が下がることがあります。Dropout率は固定の正解があるわけではなく、データセットとモデルの規模に応じて調整すべきハイパーパラメータです。
まとめ
この章では、メッセージパッシングの仕組みからGCNの数理的背景、そして実際のノード分類タスクの実装までを学びました。冒頭に掲げた学習目標を振り返ってみましょう。
- ✅ メッセージパッシングの仕組みを説明できる: Message・Aggregate・Updateの3ステップと、
MessagePassing基底クラスによる実装方法を学びました - ✅ グラフ畳み込みの数理的背景を理解する: 自己ループ付き隣接行列と対称正規化、それらを用いた層ごとの伝播規則 \(H^{(l+1)} = \sigma(\hat{A} H^{(l)} W^{(l)})\) を確認しました
- ✅ PyTorch GeometricのGCNConvレイヤーを使いこなせる: 主要パラメータ(
improved、cached、normalize等)の意味と使い分けを理解しました - ✅ Coraデータセットでノード分類タスクを実装できる: 3層GCNモデルを定義し、Coraデータセットで実際に学習を行いました
- ✅ 適切な評価指標でモデルを評価できる: 正解率に加えて、適合率・再現率・F1スコア・混同行列を用いた多角的な評価方法を学びました
- ✅ 過学習対策を実装できる: Dropout、weight_decay(L2正則化)、Early Stoppingという3つの代表的な対策を実装し、その効果を比較しました
🎉 次のステップ
お疲れさまでした。GCNの仕組みと実装を一通り体験できました。次章では、隣接ノードの重要度を学習によって重み付けする注意機構(Attention Mechanism)を取り入れたグラフニューラルネットワークなど、より高度なアーキテクチャを扱う予定です。
参考リソース