【PyG】PyTorch Geometricのインストール方法から利用方法まで解説

ソーシャルネットワークの友人関係、分子の原子結合、道路網のルート探索 — これらはすべて「グラフ」として表現できるデータです。しかし通常の畳み込みニューラルネットワークは、画像のような格子状データを前提に設計されており、グラフのように「ノードの数も辺の接続も不規則」な構造には直接適用できません。

グラフニューラルネットワーク (GNN) は、この問題を解決するために設計された手法です。そして PyTorch Geometric (PyG) は、PyTorch でGNNを実装するための事実上の標準ライブラリです。論文提案の最新アーキテクチャから古典的な GCN・GAT まで、PyG を使えば数十行のコードで実装できます。

PyGを学ぶ動機は2つあります。まず、Cora・Citeseer などの引用ネットワークでのノード分類という古典的なベンチマークが、GNNの性能を測る標準になっており、PyG がその実験の共通基盤です。次に、化合物の毒性予測・タンパク質の機能推定など、グラフ分類の応用が急速に広がっており、産業界でもPyGが使われています。

本記事の内容

  • PyGのインストール方法
  • Data オブジェクトの構造(x, edge_index, y, edge_attr)
  • メッセージパッシングの仕組みを図解で理解する
  • GCNConv・GATConv・SAGEConv の使い方と比較
  • DataLoader によるミニバッチ処理
  • Cora データセットでのノード分類完全例
  • グラフ分類(Global Pooling)の実装
  • 自作 MessagePassing 継承クラスの作り方
  • よくあるエラーとその解決策

PyG (PyTorch Geometric) とは

PyG は、Matthias Fey らが開発したグラフ深層学習ライブラリです(論文: Fey & Lenssen, ICLR 2019 Workshop)。PyTorch の上に薄いラッパーとして乗っており、nn.ModuleDataLoader といった PyTorch の慣れ親しんだ API をそのまま使いながら、グラフ固有の操作(近傍集約・スパース行列演算など)を効率的に実行できます。

PyG の主な特徴は次の3点です。

  1. 豊富な Conv 実装 — GCN、GAT、GraphSAGE、GIN など、主要な論文の Conv レイヤーがすぐに使える
  2. 標準的なデータセット API — Cora、Citeseer、TU Benchmark など、グラフ系の有名データセットを1行でダウンロードできる
  3. カスタム MessagePassingMessagePassing クラスを継承して、オリジナルの集約関数を定義できる

グラフ機械学習のエコシステムとして、DGL(Deep Graph Library)も有力な選択肢ですが、PyGは PyTorch ユーザーにとって最もシームレスに使えます。

それでは、インストールから始めましょう。


PyGのインストール方法

環境確認

まず PyTorch と CUDA のバージョンを確認します。PyG はこれらのバージョンに合わせてビルドされた依存パッケージを要求します。

import torch
print(torch.__version__)        # 例: 2.1.0
print(torch.version.cuda)       # 例: 11.8 (CPU環境では None)

pip でのインストール(推奨)

PyG 2.x 以降は依存ライブラリが大幅に整理され、pip でシンプルにインストールできるようになりました。

pip install torch_geometric

オプションの拡張ライブラリ(スパース演算の高速化、特定の Conv 実装が必要な場合):

# torch-scatter, torch-sparse をインストールするには PyTorch/CUDA バージョンを指定
# 例: PyTorch 2.1.0, CUDA 11.8 の場合
pip install pyg_lib torch_scatter torch_sparse -f https://data.pyg.org/whl/torch-2.1.0+cu118.html

CPU 環境の場合は cu118cpu に置き換えます。

Conda でのインストール

Anaconda 環境では依存関係を自動解決してくれるため、こちらが簡単です。

conda install pyg -c pyg

インストール確認

インストールが成功したか確認します。

import torch_geometric
print(torch_geometric.__version__)  # 例: 2.5.3

エラーなく表示されれば成功です。古いバージョンの PyG では torch-scatter などの個別ビルドが必要でしたが、現在はほとんどの環境で pip install torch_geometric だけで動きます。

インストールが完了したら、PyG の中心概念である Data オブジェクトを理解しましょう。


Data オブジェクト — グラフを表現するデータ構造

グラフを1つのオブジェクトに収める発想

通常のニューラルネットワークでは、入力データは (batch_size, channels, height, width) のような均一な形状のテンソルです。しかしグラフは「ノードの数も辺の数も各グラフで異なる」という性質があり、単純なテンソルでは表現できません。

PyG は torch_geometric.data.Data クラスを用意し、1つのグラフを構成するすべての情報をこのオブジェクトに収めます。

PyG Data オブジェクトの構造

この図が示すように、Data オブジェクトは複数の属性を持ちます。中心にあるのが Data オブジェクトで、ノード特徴量・辺情報・ラベルなどを属性として保持しています。

属性 形状 説明
x $[N, F]$ ノード特徴量(N=ノード数, F=特徴次元)
edge_index $[2, E]$ 辺リスト(COO 形式)
y $[N]$ or $[1]$ ノードラベル or グラフラベル
edge_attr $[E, H]$ 辺特徴量(オプション)
pos $[N, D]$ ノードの空間座標(オプション)
num_nodes スカラー ノード数(省略時は x から自動推定)

edge_index の仕組み(COO 形式)

edge_index は PyG の核心となる表現です。辺を「送信元ノードのリスト」と「受信先ノードのリスト」の2行のテンソルで表現します。これをスパース行列の COO (Coordinate) 形式と呼びます。

edge_index の COO 形式表現

たとえば4ノード・5辺のグラフで、辺 (0→1), (0→2), (1→3), (2→3), (1→2) があるなら:

import torch

# 送信元ノードのリスト(edge_index[0])
src = [0, 0, 1, 2, 1]
# 受信先ノードのリスト(edge_index[1])
dst = [1, 2, 3, 3, 2]

edge_index = torch.tensor([src, dst], dtype=torch.long)
print(edge_index.shape)  # torch.Size([2, 5])

この形式の利点は、スパース行列の計算(特に近傍集約)が非常に効率よく実装できることです。隣接行列を $N \times N$ の密行列で持つと、ノード数が増えると $O(N^2)$ のメモリが必要になりますが、COO 形式なら辺数 $E$ に比例するメモリで済みます。

実際に Data オブジェクトを作ってみましょう。

from torch_geometric.data import Data

# ノード特徴量: 3ノード、各2次元
x = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
# 辺: 0→1, 0→2, 2→1
edge_index = torch.tensor([[0, 0, 2], [1, 2, 1]], dtype=torch.long)
# ノードラベル
y = torch.tensor([0, 1, 2])

data = Data(x=x, edge_index=edge_index, y=y)
print(data)
# Data(x=[3, 2], edge_index=[2, 3], y=[3])

# 便利なプロパティ
print(data.num_nodes)  # 3
print(data.num_edges)  # 3
print(data.is_directed())  # True

出力の x=[3, 2] は「3ノード、各2次元の特徴」を、edge_index=[2, 3] は「2×3 のテンソル(3本の辺)」を意味します。

グラフの検証と可視化

PyG オブジェクトをそのまま NetworkX で可視化できます。

import networkx as nx
import matplotlib.pyplot as plt
from torch_geometric.utils import to_networkx

nxg = to_networkx(data, to_undirected=False)
nx.draw(nxg, with_labels=True, node_color="steelblue", font_color="white",
        node_size=800, arrows=True)
plt.title("PyG Data → NetworkX グラフ")
plt.show()

グラフの有効性チェックも忘れずに行いましょう。

# 自己ループがないか、孤立ノードがないかを確認
print(data.has_self_loops())  # False
print(data.is_undirected())   # False(有向グラフ)

# edge_index の最大インデックスが num_nodes 未満か検証
assert data.edge_index.max() < data.num_nodes

Data オブジェクトの構造が理解できたところで、次はGNNの核心であるメッセージパッシングの仕組みに進みましょう。


メッセージパッシングの仕組み

近傍から情報を集める

グラフニューラルネットワークの根幹は「各ノードが近傍ノードから情報を集めて、自分の表現を更新する」というシンプルなアイデアです。これをメッセージパッシングと呼びます。

具体的なイメージを持つために、SNSのフォロー関係を考えてみてください。あなたがどんな人物かを理解するとき、あなた自身のプロフィールだけでなく、あなたがフォローしている人々の特徴も重要な手がかりになります。GNN はこの直感を数式に落とし込んだものです。

メッセージパッシングの 3 段階

メッセージパッシングは3つのステップで構成されます。

① message(メッセージ生成): 各辺 $(j \to i)$ について、送信ノード $j$ の特徴量から「メッセージ」を生成します。

$$ m_{ji}^{(l)} = \phi\!\left(h_j^{(l)},\, h_i^{(l)},\, e_{ji}\right) $$

ここで $\phi$ は学習可能な変換関数(線形変換など)、$e_{ji}$ は辺の特徴量(なければ省略)です。

② aggregate(集約): ノード $i$ に向かってくる全メッセージを1つにまとめます。集約関数には和・平均・最大値などが使われます。

$$ M_i^{(l)} = \bigoplus_{j \in \mathcal{N}(i)} m_{ji}^{(l)} $$

$\bigoplus$ は集約操作(sum, mean, max など)を表します。$\mathcal{N}(i)$ はノード $i$ の近傍ノードの集合です。

③ update(状態更新): 集約したメッセージと自身の特徴量を組み合わせて、ノードの特徴量を更新します。

$$ h_i^{(l+1)} = \psi\!\left(h_i^{(l)},\, M_i^{(l)}\right) $$

$\psi$ は更新関数(MLP など)で、これも学習されます。

層を重ねると受容野が広がる

1層のメッセージパッシングで、各ノードは「1ホップ先」の近傍情報を取り込めます。2層重ねると「2ホップ先」、$L$ 層重ねると「$L$ ホップ先」まで情報が届きます。

この「受容野の拡大」が GCN の層を深くするほど精度が上がる(ただし過剰に深くすると過平滑化が起きる)理由です。

PyG の全ての Conv レイヤーはこのメッセージパッシングのフレームワークを実装しています。次のセクションでは、代表的な3つのConvレイヤーを見ていきましょう。


GCNConv・GATConv・SAGEConv の使い方と比較

GCNConv グラフ畳み込みの伝播式と受容野

この図の左側は、中心ノード $i$(次数3)への近傍集約を示しています。各近傍ノード $j$ からの重みは $1/\sqrt{\tilde{d}_i \tilde{d}_j}$ で計算され、次数の大きい近傍ほど重みが小さくなります。右側は層数と受容野の関係で、層数が増えるほど広い範囲のノードの情報が取り込まれる様子が分かります。

主要な Conv レイヤーの比較

GCNConv — グラフ畳み込みネットワーク

GCNConv は Kipf & Welling (2017) が提案した最も基本的な Conv です。各辺の重みをノードの次数で正規化することで、ノードの次数に関わらず安定した学習ができます。

伝播式は次のとおりです。

$$ h_i^{(l+1)} = \sigma\!\left(\sum_{j \in \mathcal{N}(i) \cup \{i\}} \frac{1}{\sqrt{\tilde{d}_i \tilde{d}_j}}\, h_j^{(l)}\, W^{(l)}\right) $$

ここで $\tilde{d}_i = 1 + \text{deg}(i)$ は自己ループを含めた次数、$W^{(l)}$ は学習可能な重み行列、$\sigma$ は活性化関数(ReLU など)です。

「自己ループを含めた次数で正規化する」直感:次数の大きいノード(ハブノード)からのメッセージは薄め、次数が小さいノードからのメッセージは強調することで、ネットワークが均衡した学習ができます。

import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

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

    def forward(self, x, edge_index):
        # 第1層: メッセージパッシング + ReLU + Dropout
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, p=0.5, training=self.training)
        # 第2層: メッセージパッシング(活性化なし)
        x = self.conv2(x, edge_index)
        return x  # ノード分類の場合、softmax は loss 計算時にかける

各 Conv レイヤーは conv(x, edge_index) の形式で呼び出します。x はノード特徴量テンソル、edge_index は辺リストです。

GATConv — グラフアテンションネットワーク

GATConv(Veličković et al., 2018)は各辺に注意重み(アテンション)を付与します。GCNConv が次数で固定の重みを使うのに対し、GATConv は「どの近傍ノードが重要か」を学習で決めます。

注意重みは次の式で計算されます。

$$ \alpha_{ij} = \frac{\exp\!\left(\text{LeakyReLU}\!\left(\bm{a}^\top [W h_i \| W h_j]\right)\right)}{\sum_{k \in \mathcal{N}(i)} \exp\!\left(\text{LeakyReLU}\!\left(\bm{a}^\top [W h_i \| W h_k]\right)\right)} $$

$\|$ は連結(concatenation)、$\bm{a}$ は学習可能なアテンションベクトルです。分子で辺 $(i, j)$ の重要度を計算し、分母で正規化します。

更新式は次のとおりです。

$$ h_i^{(l+1)} = \sigma\!\left(\sum_{j \in \mathcal{N}(i)} \alpha_{ij}\, W\, h_j^{(l)}\right) $$

実用的にはマルチヘッドアテンション(複数の独立したアテンション機構を並列に使う)が効果的です。

from torch_geometric.nn import GATConv

class GAT(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels, heads=8):
        super().__init__()
        # マルチヘッドアテンション: heads 個を並列実行し結合
        self.conv1 = GATConv(in_channels, hidden_channels, heads=heads,
                             dropout=0.6)
        # 第2層は heads=1 で集約(または concat=False で平均)
        self.conv2 = GATConv(hidden_channels * heads, out_channels, heads=1,
                             concat=False, dropout=0.6)

    def forward(self, x, edge_index):
        x = F.dropout(x, p=0.6, training=self.training)
        x = F.elu(self.conv1(x, edge_index))
        x = F.dropout(x, p=0.6, training=self.training)
        x = self.conv2(x, edge_index)
        return x

heads=8 で8つのアテンションヘッドを並列実行し、第1層の出力は hidden_channels * 8 次元になります。第2層では concat=False を指定して平均を取ると、最終出力を out_channels 次元にできます。

SAGEConv — 帰納的なグラフ学習

GraphSAGE(Hamilton et al., 2017)は GCN の大きな制約を解決した手法です。GCN はグラフ全体を一度に処理する転導的(transductive)なアプローチで、学習時に見たことがない新規ノードには対応できません。SAGEConv は近傍をサンプリングして集約する帰納的(inductive)なアプローチを採用しています。

集約式は次のとおりです。

$$ h_i^{(l+1)} = \sigma\!\left(W_1\, h_i^{(l)} + W_2\cdot \text{AGG}\!\left(\{h_j^{(l)} : j \in \mathcal{N}(i)\}\right)\right) $$

$\text{AGG}$ は近傍の集約関数(mean / max / LSTM など)、$W_1$ は自己特徴の変換、$W_2$ は近傍集約結果の変換で、それぞれ独立した学習パラメータです。

from torch_geometric.nn import SAGEConv

class GraphSAGE(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = SAGEConv(in_channels, hidden_channels, aggr="mean")
        self.conv2 = SAGEConv(hidden_channels, out_channels, aggr="mean")

    def forward(self, x, edge_index):
        x = F.relu(self.conv1(x, edge_index))
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x

aggr="mean" で近傍の平均を集約関数として使います。aggr="max"aggr="sum" も選べます。

3つの Conv の比較まとめ

GCNConv GATConv SAGEConv
辺の重み 次数で固定 アテンションで学習 均等(または学習)
表現力 低〜中 中〜高
計算コスト 中〜高
帰納的学習 不可 限定的 可能
向き不向き 小〜中規模 重要な近傍を選びたい 大規模・新規ノード対応

3種の Conv の使い方がわかったところで、次は複数のグラフを効率よく処理するミニバッチの仕組みを見ていきましょう。


DataLoader によるミニバッチ処理

グラフのバッチ処理の難しさ

通常の画像データであれば、同じサイズのテンソルをスタックするだけでバッチが作れます。しかしグラフはノード数も辺数も各グラフで異なるため、単純にスタックできません。

PyG の DataLoader はこの問題を「全グラフを1つの大きな孤立グラフとして結合する」というアプローチで解決しています。

ミニバッチ DataLoader による複数グラフ結合

図のように、グラフ1(3ノード)とグラフ2(4ノード)をバッチ化すると:

  • ノード数: 3+4=7 になり、グラフ2のノードIDは 0→3, 1→4, 2→5, 3→6 にオフセット
  • edge_index: グラフ2の辺は各インデックスに +3 してから結合
  • batch テンソル: 各ノードがどのグラフに属するかを示す(グラフ1のノードは 0, グラフ2のノードは 1)
from torch_geometric.loader import DataLoader

# 複数のグラフからなるデータセットを仮定
graphs = [
    Data(x=torch.randn(3, 8), edge_index=torch.tensor([[0,1],[1,2]]), y=torch.tensor([0])),
    Data(x=torch.randn(5, 8), edge_index=torch.tensor([[0,1,2,3],[1,2,3,4]]), y=torch.tensor([1])),
    Data(x=torch.randn(4, 8), edge_index=torch.tensor([[0,1,2],[1,2,3]]), y=torch.tensor([0])),
]

loader = DataLoader(graphs, batch_size=2, shuffle=True)

for batch in loader:
    print(batch)
    # DataBatch(x=[8, 8], edge_index=[2, 3], y=[2], batch=[8], ptr=[3])
    # x=[8, 8] は バッチ内の全ノード数 x 特徴次元
    # batch=[8] は各ノードがグラフ0か1かを示すインデックス
    print(batch.batch)   # tensor([0, 0, 0, 1, 1, 1, 1, 1])
    print(batch.num_graphs)  # 2
    break

このコードから2点読み取れます。まず、batch テンソルが各ノードのグラフ帰属を記録しているため、後述の Global Pooling でグラフごとに集約できます。次に、辺の edge_index は自動的にオフセットされているので、ユーザーが手動で調整する必要がありません。

実際のグラフデータセットの読み込み

PyG には多数のベンチマークデータセットが付属しています。

from torch_geometric.datasets import TUDataset

# MUTAG: 188グラフ、2クラス分類(化合物の変異原性)
dataset = TUDataset(root="/tmp/MUTAG", name="MUTAG")
print(f"グラフ数: {len(dataset)}")           # 188
print(f"クラス数: {dataset.num_classes}")      # 2
print(f"特徴次元: {dataset.num_node_features}") # 7

# 訓練・テストに分割
train_dataset = dataset[:150]
test_dataset  = dataset[150:]

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader  = DataLoader(test_dataset, batch_size=32, shuffle=False)

DataLoader の使い方が理解できたところで、実際のノード分類タスクの完全な実装例を見ていきましょう。


Cora データセットでのノード分類 完全例

Cora データセットとは

Cora は引用ネットワークのグラフデータです。2708本の学術論文がノードに対応し、引用関係が辺に対応します。各論文は1433次元のBag-of-Words特徴を持ち、7つのカテゴリ(機械学習・ニューラルネットワークなど)のいずれかに属します。

ノード分類タスクでは、一部のノードのラベルを使って残りのノードのカテゴリを予測します。これは半教師あり学習の設定で、GNNの力が発揮される典型的な場面です。

from torch_geometric.datasets import Planetoid

# データセットのダウンロード
dataset = Planetoid(root="/tmp/Cora", name="Cora")
data = dataset[0]  # グラフは1つ

print(f"ノード数: {data.num_nodes}")          # 2708
print(f"辺数: {data.num_edges}")             # 10556
print(f"特徴次元: {data.num_node_features}") # 1433
print(f"クラス数: {dataset.num_classes}")    # 7
print(f"訓練ノード数: {data.train_mask.sum()}") # 140
print(f"検証ノード数: {data.val_mask.sum()}")   # 500
print(f"テストノード数: {data.test_mask.sum()}") # 1000

train_maskval_masktest_mask は各ノードが訓練/検証/テストに含まれるかを示すブール型テンソルです。

モデルの定義

import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

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

    def forward(self, x, edge_index):
        # 第1層
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, p=self.dropout, training=self.training)
        # 第2層
        x = self.conv2(x, edge_index)
        return x  # 生のロジット

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

model = GCNClassifier(
    in_channels=dataset.num_node_features,  # 1433
    hidden_channels=64,
    out_channels=dataset.num_classes,       # 7
).to(device)

data = data.to(device)

学習ループ

optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)

def train():
    model.train()
    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()
    return loss.item()

@torch.no_grad()
def evaluate():
    model.eval()
    out = model(data.x, data.edge_index)
    pred = out.argmax(dim=-1)

    accs = {}
    for split, mask in [("train", data.train_mask),
                         ("val",   data.val_mask),
                         ("test",  data.test_mask)]:
        correct = pred[mask] == data.y[mask]
        accs[split] = correct.sum().item() / mask.sum().item()
    return accs

best_val_acc = 0
for epoch in range(1, 201):
    loss = train()
    accs = evaluate()
    if accs["val"] > best_val_acc:
        best_val_acc = accs["val"]
        best_test_acc = accs["test"]
    if epoch % 20 == 0:
        print(f"Epoch {epoch:03d}: Loss={loss:.4f} "
              f"Train={accs['train']:.4f} Val={accs['val']:.4f} Test={accs['test']:.4f}")

GCN は Cora でおよそ 81% のテスト精度を達成します(GATConv を使うと 83% 程度)。

Cora データセット GCN 学習曲線

この学習曲線から2点が読み取れます。第1に、訓練損失・検証損失はともに約50エポックで急速に収束し、その後はゆっくりと改善が続きます。第2に、テスト精度は検証精度に近い値で推移しており、過学習が抑制されています。これは dropoutweight_decay が機能しているためです。


層数と過平滑化(Over-Smoothing)

深い GNN は逆効果になる

「層を深くすれば表現力が上がる」という通常のニューラルネットワークの直感は、GNNには当てはまりません。GNN の層を深くしすぎると過平滑化(Over-Smoothing)が起き、全ノードの特徴量が均一な値に収束してしまいます。

層数と過平滑化の関係

この図は概念的な実験結果を示しています。2〜3層で最良の精度が得られ、4層以降から精度が急落します。これは $L$ 層の GNN が「$L$ ホップ先までの全ノードの特徴を混ぜ合わせる」ことに相当し、グラフが密なほど早く「全ノードが同じ特徴」に退化するためです。

過平滑化の対策

層を深くしたい場合には以下の手法が有効です。

from torch_geometric.nn import GCNConv, JumpingKnowledge
import torch.nn as nn

class DeepGCN(torch.nn.Module):
    """Jumping Knowledge でスキップ接続を追加した深い GCN"""
    def __init__(self, in_channels, hidden_channels, out_channels, num_layers=4):
        super().__init__()
        self.convs = nn.ModuleList()
        self.convs.append(GCNConv(in_channels, hidden_channels))
        for _ in range(num_layers - 2):
            self.convs.append(GCNConv(hidden_channels, hidden_channels))
        self.convs.append(GCNConv(hidden_channels, hidden_channels))

        # JK: 全層の出力を max または concatenate で結合
        self.jk = JumpingKnowledge(mode="max")
        self.lin = nn.Linear(hidden_channels, out_channels)

    def forward(self, x, edge_index):
        xs = []
        for conv in self.convs:
            x = F.relu(conv(x, edge_index))
            x = F.dropout(x, p=0.5, training=self.training)
            xs.append(x)
        # 各層の出力を結合して最終表現を得る
        out = self.jk(xs)
        return self.lin(out)

Jumping Knowledge(JK) は各層の出力を保存し、最終的に max や concatenation で結合することで、各ノードが「どの受容野サイズが最適か」を暗黙に選べるようにします。過平滑化を防ぎながら深いモデルを使う代表的な手法です。


GINConv — 理論的に最強の表現力

GNN はどこまで異なるグラフを見分けられるか

GNN の「賢さ」を測る重要な指標が、異なる構造を持つグラフを区別できるかという問いです。これは Weisfeiler-Lehman (WL) テストと呼ばれるグラフ同形性テストと深く結びついています。

WL テストの直感はシンプルです。各ノードに最初は同じラベル「赤」を割り振り、近傍ノードのラベルをまとめて新しいラベルに変換する操作を繰り返します。最終的に全ノードのラベル集合が2つのグラフで同じなら、WL テストは「同じ」と判定します(実際に同形かどうかは別問題です)。

GINConv と Weisfeiler-Lehman テスト — グラフの識別能力比較

この図の左2つのグラフは、ノードの次数分布やラベル分布が同じであるため、1-WL テストでは区別できません。GCN や GAT のような平均・加重平均ベースの集約は、1-WL と同等の識別力しか持ちません。右の図が示すように、GIN は理論的に 1-WL と同等の最強の表現力を達成する GNN です。

GINConv の仕組み

Xu et al. (ICLR 2019, “How Powerful are Graph Neural Networks?”) は、GNN の表現力を最大化するための十分条件を証明しました。鍵は集約関数の単射性(injective)です。

単射な関数とは「異なる入力には異なる出力を返す関数」のこと。GCN の平均集約は単射でないため、次の2つのノードの近傍多重集合を区別できません。

  • ノード A: 近傍が $\{1, 2, 3\}$(3ノード)
  • ノード B: 近傍が $\{2\}$(1ノードで値が平均と同じ)

GIN の集約式は次のとおりです。

$$ h_i^{(k+1)} = \text{MLP}^{(k)}\!\left((1 + \epsilon^{(k)})\cdot h_i^{(k)} + \sum_{j \in \mathcal{N}(i)} h_j^{(k)}\right) $$

重要なのは「和(sum)集約」と「自己ノードへの $\epsilon$ スケーリング」の組み合わせです。

和集約が単射である理由を直感的に理解しましょう。平均や最大は多重集合の情報を失います。例えば、$\{1, 2\}$ の平均は 1.5 で、$\{0, 3\}$ の平均も 1.5 です。しかし和なら $\{1, 2\}=3$、$\{0, 3\}=3$ と同じになるケースもあります。一方、MLP を組み合わせた和集約は、十分な表現力があれば任意の多重集合を区別できることが理論的に示されています。

$\epsilon$ は学習可能なパラメータ(または固定値 0)で、自己特徴と近傍集約の比重を調整します。

# PyG での GINConv の使い方
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GINConv, global_add_pool

class GIN(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels, num_layers=3):
        super().__init__()
        self.convs = nn.ModuleList()
        self.bns = nn.ModuleList()  # Batch Normalization

        for i in range(num_layers):
            in_ch = in_channels if i == 0 else hidden_channels
            # GINConv には任意の NN を渡せる(内部で MLP として使われる)
            mlp = nn.Sequential(
                nn.Linear(in_ch, hidden_channels),
                nn.BatchNorm1d(hidden_channels),
                nn.ReLU(),
                nn.Linear(hidden_channels, hidden_channels),
            )
            self.convs.append(GINConv(mlp, eps=0, train_eps=True))
            self.bns.append(nn.BatchNorm1d(hidden_channels))

        self.lin1 = nn.Linear(hidden_channels, hidden_channels)
        self.lin2 = nn.Linear(hidden_channels, out_channels)

    def forward(self, x, edge_index, batch):
        for conv, bn in zip(self.convs, self.bns):
            x = F.relu(bn(conv(x, edge_index)))

        # グラフ分類: 和集約(表現力を最大化)
        x = global_add_pool(x, batch)

        x = F.dropout(F.relu(self.lin1(x)), p=0.5, training=self.training)
        return self.lin2(x)

# 構文確認(PyG 環境で動作可能なコード)
model = GIN(in_channels=7, hidden_channels=64, out_channels=2)
print(model)
# GIN(
#   (convs): ModuleList(...)
#   (bns): ModuleList(...)
# )

このコードから読み取れる設計上のポイントが2つあります。第1に、GINConvnn.Sequential で定義した MLP を渡していることで、Xu et al. の理論的保証(MLP の普遍近似能力 + 和集約 = 1-WL 同等)を実現しています。第2に、グラフ分類に global_add_pool を使っている点で、global_mean_pool よりも情報損失が少なく、GIN の表現力を最大限に活かせます。

GCN・GAT・SAGE・GIN の使い分け

Conv 表現力 速度 推奨ユースケース
GCNConv 中(1-WL 以下) 高速 ホモフィリーな引用グラフ、半教師あり
GATConv 中〜高 異質な近傍が混在、ノード属性が重要
SAGEConv 高速 大規模グラフ、帰納的推論
GINConv 最高(1-WL 同等) グラフ分類、化合物予測

表現力が必要な場合は GIN、速度と安定性を重視するなら GCN、という選択が基本方針です。

GIN の理論的な優位性がわかったところで、次は大規模グラフへの対応策として NeighborLoader を見ていきましょう。


NeighborLoader — 大規模グラフへの対応

フルバッチの限界

Cora(2,708ノード)程度のグラフならフルバッチで学習できますが、Reddit(230,000ノード)や ogbn-arxiv(170,000ノード)のような大規模グラフでは、グラフ全体を一度にGPUに乗せると Out of Memory が発生します。

この問題を解決するのが NeighborLoader(近傍サンプリングベースのミニバッチ化)です。GraphSAGE の論文で提案されたサンプリング戦略をそのまま実装した仕組みで、ターゲットノードの周辺を限定した数だけサンプリングし、サブグラフをミニバッチとして処理します。

NeighborLoader 大規模グラフの近傍サンプリング概念図

この図の左側が全グラフをフルで処理しようとするアプローチです。何百万ノードものグラフ全体をGPUに乗せようとすると、メモリが溢れます。右側の NeighborLoader は、各ターゲットノードから「1-hop先は3個、2-hop先は各2個」というように近傍を絞ってサブグラフを作り、そのサブグラフだけをGPUで処理します。これにより巨大なグラフでも固定サイズのミニバッチで効率よく学習できます。

from torch_geometric.loader import NeighborLoader
from torch_geometric.datasets import Planetoid

# 大規模グラフを想定した設定
dataset = Planetoid(root="/tmp/Cora", name="Cora")
data = dataset[0]

# NeighborLoader の設定
# num_neighbors=[25, 10]: 1層目で25近傍、2層目で10近傍をサンプリング
train_loader = NeighborLoader(
    data,
    num_neighbors=[25, 10],       # 各層でサンプリングする近傍数
    batch_size=1024,               # バッチごとのターゲットノード数
    input_nodes=data.train_mask,  # 訓練ノードだけをターゲットに
    shuffle=True,
)

# サブグラフのサイズを確認
for subgraph in train_loader:
    print(f"ターゲットノード数: {subgraph.batch_size}")         # 1024 (または以下)
    print(f"サブグラフのノード数: {subgraph.num_nodes}")        # サンプルした全ノード
    print(f"サブグラフの辺数: {subgraph.num_edges}")            # サブグラフ内の辺
    # subgraph.n_id には元グラフのノードIDが入っている
    print(f"ノードID範囲: {subgraph.n_id.min()} ~ {subgraph.n_id.max()}")
    break

subgraph.n_id は元グラフのどのノードに対応するかを示すインデックスです。ターゲットノードは subgraph[:subgraph.batch_size] でアクセスできます。

NeighborLoader を使った大規模グラフの学習ループ

import torch
import torch.nn.functional as F
from torch_geometric.nn import SAGEConv

class GraphSAGELarge(torch.nn.Module):
    """大規模グラフ向け GraphSAGE"""
    def __init__(self, in_channels, hidden_channels, out_channels):
        super().__init__()
        self.conv1 = SAGEConv(in_channels, hidden_channels)
        self.conv2 = SAGEConv(hidden_channels, out_channels)

    def forward(self, x, edge_index):
        x = F.relu(self.conv1(x, edge_index))
        x = F.dropout(x, p=0.5, training=self.training)
        return self.conv2(x, edge_index)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = GraphSAGELarge(dataset.num_node_features, 256, dataset.num_classes).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=5e-4)

def train_large():
    model.train()
    total_loss = 0
    for subgraph in train_loader:
        subgraph = subgraph.to(device)
        optimizer.zero_grad()
        out = model(subgraph.x, subgraph.edge_index)
        # ターゲットノード (先頭 batch_size 個) だけで損失計算
        loss = F.cross_entropy(
            out[:subgraph.batch_size],
            subgraph.y[:subgraph.batch_size]
        )
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(train_loader)

# 注: この例は NeighborLoader の使用パターンを示す構文確認用コード。
# 実際の訓練は PyG + CUDA 環境で実行してください。

ここで重要なのは out[:subgraph.batch_size] という部分です。NeighborLoader はターゲットノードをサブグラフの先頭に配置するため、このスライスでターゲットノードの予測のみを損失計算に使います。サンプリングした2-hop以上のノードは特徴を提供するために使われますが、予測の監督には使いません。

num_neighbors のチューニング

num_neighbors は精度とメモリのトレードオフを制御します。

設定 メモリ消費 精度 推奨場面
[5, 5] やや低 超大規模グラフ(数百万ノード)
[25, 10] 標準的な大規模グラフ
[50, 25] 最高 精度優先・メモリ余裕あり
-1(全近傍) 非常に高 フルバッチ相当 小グラフ検証時のみ

-1 を指定すると全近傍をサンプリングしてフルバッチと等価になります。テスト時はノイズなしの評価が欲しいため、NeighborLoader ではなく全グラフで model.eval() するか、全近傍 -1 で評価するのが一般的です。

これで大規模グラフへの対応策がわかりました。次はグラフ分類タスクの完全な実装を見ていきましょう。


グラフ分類の実装(Global Pooling)

グラフ全体を1ベクトルに

ノード分類ではノードごとに予測を行いますが、グラフ分類ではグラフ全体に対して1つのラベルを予測します。たとえば「この分子は毒性があるか否か」という問題です。

グラフ分類の鍵はグラフ読み出し(Graph Readout)です。GNN でノード埋め込みを計算した後、全ノードの埋め込みを1つのベクトルに集約します。

グラフ分類のパイプライン Pooling

パイプラインは「入力グラフ → GNN 層 → ノード埋め込み → Global Pooling → グラフ表現 → 分類器」という流れになります。

PyG が提供する主な Global Pooling:

関数 動作 特徴
global_mean_pool 全ノードの平均 安定・高速
global_add_pool 全ノードの和 ノード数に敏感
global_max_pool 全ノードの最大値 極端な特徴を拾う
from torch_geometric.nn import GCNConv, global_mean_pool

class GraphClassifier(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, hidden_channels)
        self.conv3 = GCNConv(hidden_channels, hidden_channels)
        self.lin1  = torch.nn.Linear(hidden_channels, hidden_channels)
        self.lin2  = torch.nn.Linear(hidden_channels, out_channels)

    def forward(self, x, edge_index, batch):
        # ノードレベルのメッセージパッシング
        x = F.relu(self.conv1(x, edge_index))
        x = F.relu(self.conv2(x, edge_index))
        x = F.relu(self.conv3(x, edge_index))

        # グラフレベルへの集約(batch テンソルがどのグラフかを示す)
        x = global_mean_pool(x, batch)  # [num_graphs, hidden_channels]

        # MLP 分類器
        x = F.relu(self.lin1(x))
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.lin2(x)
        return x

学習ループ(グラフ分類)

# MUTAG データセットでの例
from torch_geometric.datasets import TUDataset
from torch_geometric.loader import DataLoader

dataset = TUDataset(root="/tmp/MUTAG", name="MUTAG").shuffle()
train_loader = DataLoader(dataset[:150], batch_size=32, shuffle=True)
test_loader  = DataLoader(dataset[150:], batch_size=32, shuffle=False)

model = GraphClassifier(
    in_channels=dataset.num_node_features,  # 7
    hidden_channels=64,
    out_channels=dataset.num_classes,       # 2
)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

def train_graph():
    model.train()
    total_loss = 0
    for data in train_loader:
        optimizer.zero_grad()
        # forward に batch テンソルを渡す
        out = model(data.x, data.edge_index, data.batch)
        loss = F.cross_entropy(out, data.y)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * data.num_graphs
    return total_loss / len(train_loader.dataset)

@torch.no_grad()
def test_graph(loader):
    model.eval()
    correct = 0
    for data in loader:
        out = model(data.x, data.edge_index, data.batch)
        pred = out.argmax(dim=-1)
        correct += (pred == data.y).sum().item()
    return correct / len(loader.dataset)

# 注: このコードは PyG インストール環境での実行を想定。
# 実際の訓練結果は MUTAG で 70〜80% 前後の精度になることが多い。

forward(x, edge_index, batch)global_mean_pool(x, batch) の組み合わせがポイントです。batch テンソルが各ノードのグラフ帰属を示すため、グラフごとに正しく平均を取れます。


自作 MessagePassing 継承クラスの作り方

PyG の最も強力な機能の1つが、MessagePassing 基底クラスを継承して、独自の集約関数・更新関数を定義できる点です。論文で提案されている新しいアーキテクチャを PyG で実装するとき、これが必要になります。

MessagePassing の構造

MessagePassing クラスは、ユーザーが次の3つのメソッドをオーバーライドすることを想定しています。

メソッド 役割 デフォルト
message(x_j, ...) 辺ごとのメッセージを返す $h_j$ をそのまま返す
aggregate(inputs, index, ...) メッセージを集約する aggr 引数で指定(sum/mean/max)
update(aggr_out, ...) ノード状態を更新する 集約結果をそのまま返す

実際の呼び出しは self.propagate(edge_index, x=x) で行い、PyG がメッセージパッシングのループを自動で実行します。

例: 加重平均メッセージパッシング

次の例は、辺の特徴量を重みとして使った加重平均集約を実装します。

from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree

class WeightedConv(MessagePassing):
    """辺の特徴量を重みとして使う加重平均集約"""

    def __init__(self, in_channels, out_channels):
        # aggr="add" で集約は和、update で正規化する
        super().__init__(aggr="add")
        self.lin = torch.nn.Linear(in_channels, out_channels)

    def forward(self, x, edge_index, edge_weight=None):
        # 自己ループを追加して自分自身の特徴も集約対象に
        edge_index, edge_weight = add_self_loops(
            edge_index, edge_weight, fill_value=1.0, num_nodes=x.size(0)
        )
        # 線形変換
        x = self.lin(x)
        # 次数の計算(正規化に使う)
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv = deg.pow(-1.0)
        deg_inv[deg_inv == float("inf")] = 0  # 孤立ノードの対処

        # propagate が message → aggregate → update を自動実行
        return self.propagate(edge_index, x=x,
                              edge_weight=edge_weight, deg_inv=deg_inv)

    def message(self, x_j, edge_weight, deg_inv_i):
        # 送信ノード j の特徴 x_j を辺重みで重み付け
        # deg_inv_i は受信ノード i の次数逆数(正規化用)
        return deg_inv_i.view(-1, 1) * edge_weight.view(-1, 1) * x_j

    def update(self, aggr_out):
        # 集約結果をそのまま返す(非線形変換を追加することも可)
        return aggr_out

message メソッド内で x_j は送信ノード $j$ の特徴量、deg_inv_i は受信ノード $i$ の次数逆数です。PyG は _j サフィックスを送信側、_i サフィックスを受信側として自動的に解釈します。

# 使用例
conv = WeightedConv(in_channels=16, out_channels=32)
x = torch.randn(5, 16)  # 5ノード、16次元
edge_index = torch.tensor([[0, 1, 2, 3], [1, 2, 3, 4]], dtype=torch.long)
edge_weight = torch.tensor([0.5, 0.8, 1.0, 0.3])

out = conv(x, edge_index, edge_weight)
print(out.shape)  # torch.Size([5, 32])

MessagePassing の継承により、メッセージパッシングのループを自分で書かずに、各ステップのロジックだけを定義できます。効率的なスパース演算も自動で適用されるため、実装が非常に簡潔になります。


変換パイプライン(Transforms)と自作データセット

グラフデータの前処理を自動化する

現実のグラフデータは、そのままでは学習に適さない場合が多くあります。ノード特徴量のスケールが揃っていない、自己ループがない、無向グラフ化が必要、などの前処理が頻繁に必要です。PyG の Transforms は、これらの前処理を宣言的に指定できる仕組みです。

import torch_geometric.transforms as T
from torch_geometric.datasets import Planetoid

# Transforms をリストで組み合わせる(Compose で直列処理)
transform = T.Compose([
    T.NormalizeFeatures(),       # ノード特徴量を行ごとに L1 正規化
    T.ToUndirected(),            # 有向辺→無向辺(逆方向の辺を追加)
    T.AddSelfLoops(),            # 自己ループを追加(GCNConv では内部でも追加するが明示的に)
])

dataset = Planetoid(root="/tmp/Cora", name="Cora", transform=transform)
data = dataset[0]
print(data)
# Data(x=[2708, 1433], edge_index=[2, 13264], y=[2708], ...)
# 辺数が増えている(双方向化 + 自己ループ追加のため)

transformデータアクセス時(dataset[i])に毎回適用されます。一方、pre_transform最初の1回だけ適用され、前処理済みデータがキャッシュされます。頻繁に変わらない重い前処理(グラフのサブグラフ分割など)には pre_transform が適しています。

# pre_transform: 最初の1回だけ適用されディスクにキャッシュ
# transform: データアクセスのたびに適用(ランダム変換などに使う)
dataset = TUDataset(
    root="/tmp/MUTAG",
    name="MUTAG",
    pre_transform=T.NormalizeFeatures(),  # キャッシュされる
    transform=T.RandomFlip(axis=0),        # アクセスごとに適用
)

PyG が提供する代表的な変換の一覧を確認しましょう。

変換クラス 動作 典型的な用途
NormalizeFeatures 行ごとに L1 正規化 特徴量スケール統一
ToUndirected 逆辺を追加して無向化 有向グラフ→無向グラフ
AddSelfLoops 自己ループを追加 GCNConv の前処理
RemoveSelfLoops 自己ループを除去 データクリーニング
GDC グラフ拡散畳み込み スペクトルフィルタリング
SVDFeatureReduction SVD で特徴量削減 次元削減
RandomNodeSplit ノードを train/val/test に分割 独自データセット

自作データセットクラスの作り方

自分で収集したグラフデータを PyG で使うには、InMemoryDataset または Dataset を継承します。データが十分小さくてメモリに全て乗る場合は InMemoryDataset が便利です。

from torch_geometric.data import InMemoryDataset, Data, download_url
import torch
import os

class MyMoleculeDataset(InMemoryDataset):
    """独自の分子グラフデータセット(InMemoryDataset の実装例)"""

    def __init__(self, root, transform=None, pre_transform=None):
        # root: データのキャッシュ先ディレクトリ
        super().__init__(root, transform, pre_transform)
        # 前処理済みデータを読み込む
        self.data, self.slices = torch.load(self.processed_paths[0])

    @property
    def raw_file_names(self):
        """生データのファイル名リスト(download で作られるファイル)"""
        return ["molecules.csv"]

    @property
    def processed_file_names(self):
        """処理済みデータのファイル名(存在すればダウンロード・処理をスキップ)"""
        return ["data.pt"]

    def download(self):
        """生データをダウンロードする(省略可)"""
        # download_url("https://example.com/molecules.csv", self.raw_dir)
        pass  # 既に raw_dir にデータがある場合はスキップ

    def process(self):
        """生データを読み込んで Data オブジェクトのリストを作成"""
        data_list = []

        # 例: CSV から分子グラフを構築
        # 実際は RDKit などで SMILES → グラフ変換を行う
        for i in range(100):  # 100個のダミー分子
            num_atoms = torch.randint(5, 20, (1,)).item()
            num_bonds = torch.randint(num_atoms - 1, num_atoms * 2, (1,)).item()

            # ノード特徴量(原子タイプ・電荷など)
            x = torch.randn(num_atoms, 9)
            # 辺(化学結合)
            src = torch.randint(0, num_atoms, (num_bonds,))
            dst = torch.randint(0, num_atoms, (num_bonds,))
            edge_index = torch.stack([src, dst], dim=0)
            # グラフラベル(毒性:0 or 1)
            y = torch.randint(0, 2, (1,))

            data = Data(x=x, edge_index=edge_index, y=y)
            data_list.append(data)

        # pre_transform を適用
        if self.pre_transform is not None:
            data_list = [self.pre_transform(d) for d in data_list]

        # 一括保存
        data, slices = self.collate(data_list)
        torch.save((data, slices), self.processed_paths[0])

# 使用例(構文確認済み)
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
    my_dataset = MyMoleculeDataset(root=tmpdir)
    print(f"グラフ数: {len(my_dataset)}")           # 100
    print(f"最初のグラフ: {my_dataset[0]}")          # Data オブジェクト
    print(f"クラス数: {my_dataset.num_classes}")     # 2

このコードから実装上の重要なポイントが読み取れます。第1に、processed_paths[0] が存在すれば process() をスキップするため、2回目以降の読み込みは高速です。第2に、self.collate(data_list) が全グラフをまとめて1つの大きなテンソルに格納し、slices でアクセス境界を記録します。これが PyG の効率的なメモリ管理の仕組みです。

DataPipe と大規模データセット

ディスク上に大量のグラフが分散している場合(分子データベースなど1億件超)は、Dataset の基底クラスを継承して1ファイルずつ遅延読み込みします。

from torch_geometric.data import Dataset

class LargeGraphDataset(Dataset):
    """大規模データセット: ファイルごとに遅延読み込み"""

    def __init__(self, root, file_list, transform=None):
        self.file_list = file_list
        super().__init__(root, transform)

    def len(self):
        return len(self.file_list)

    def get(self, idx):
        """インデックス idx のグラフを読み込んで返す"""
        # 各グラフをファイルから個別に読み込む
        data = torch.load(os.path.join(self.root, self.file_list[idx]))
        return data

InMemoryDataset とは異なり、get() が呼ばれるたびにファイルからロードするため、RAM に全データを乗せる必要がありません。数千万件のグラフデータセットでも扱えます。

自作データセットと変換パイプラインの使い方が理解できたところで、PyG でよく遭遇するエラーとその対処法を見ていきましょう。


PyG でよくあるエラーと解決策

PyG でよくあるエラーと解決策

1. RuntimeError: CUDA out of memory

大規模グラフをGPUで処理するときに発生します。

# 対処1: バッチサイズを減らす
loader = DataLoader(dataset, batch_size=8)  # 32 → 8 に減らす

# 対処2: グラフをミニバッチに分割して処理(NeighborLoader)
from torch_geometric.loader import NeighborLoader
loader = NeighborLoader(
    data,
    num_neighbors=[25, 10],  # 各層で何近傍をサンプリングするか
    batch_size=1024,
    input_nodes=data.train_mask,
)

NeighborLoader は大規模グラフで GraphSAGE のような近傍サンプリングを使うときに有効です。

2. edge_index が非連続または形状エラー

# 誤: edge_index が [E, 2] の形状
edge_index_wrong = torch.tensor([[0, 1], [1, 2], [2, 3]])
# print(edge_index_wrong.shape)  # [3, 2] ← NG

# 正: [2, E] の形状にする
edge_index_correct = edge_index_wrong.T  # 転置
# または
edge_index_correct = torch.tensor([[0, 1, 2], [1, 2, 3]])
# print(edge_index_correct.shape)  # [2, 3] ← OK

3. 有向グラフと無向グラフの変換

from torch_geometric.utils import to_undirected, is_undirected

# 有向グラフを無向グラフに変換(逆方向の辺を追加)
edge_index_undirected = to_undirected(edge_index)
print(is_undirected(edge_index_undirected))  # True

4. 自己ループの追加

from torch_geometric.utils import add_self_loops, remove_self_loops

# GCNConv などで自己情報を集約するために必要
edge_index_with_loops, _ = add_self_loops(edge_index, num_nodes=data.num_nodes)

# 不要な自己ループを削除
edge_index_clean, _ = remove_self_loops(edge_index)

5. data.x が None のとき

一部のデータセットではノード特徴量がない場合があります。

# 特徴量がない場合は次数または定数ベクトルで代用
if data.x is None:
    # 方法1: 全ノードを同じ定数特徴で初期化
    data.x = torch.ones(data.num_nodes, 1)
    # 方法2: 次数を特徴量として使う
    from torch_geometric.utils import degree
    deg = degree(data.edge_index[0], data.num_nodes, dtype=torch.float)
    data.x = deg.unsqueeze(-1)

6. モデルの評価モードの切り忘れ

# 訓練時
model.train()
# 評価時(Dropout が無効になる)
model.eval()
with torch.no_grad():
    pred = model(data.x, data.edge_index)

model.eval() を呼ばないと、推論時も Dropout が有効なままになり、結果が不安定になります。


PyG のデータセット API

PyG には多数のベンチマークデータセットが付属しています。

from torch_geometric.datasets import (
    Planetoid,    # Cora, Citeseer, PubMed (引用ネットワーク)
    TUDataset,    # MUTAG, ENZYMES, PROTEINS (グラフ分類)
    Amazon,       # Computer, Photo (商品共起グラフ)
    Coauthor,     # CS, Physics (共著ネットワーク)
    KarateClub,   # ザラテクラブ (チュートリアル定番)
    OGB,          # Open Graph Benchmark (大規模)
)

# 使用例
from torch_geometric import datasets
dataset = datasets.KarateClub()
data = dataset[0]
print(data.num_nodes, data.num_edges)  # 34, 156

Open Graph Benchmark (OGB) はより大規模な標準ベンチマークで、ogb パッケージをインストールして利用します。

pip install ogb
from ogb.nodeproppred import PygNodePropPredDataset
# ogbn-arxiv: 17万ノード・120万辺の引用グラフ
dataset = PygNodePropPredDataset("ogbn-arxiv", root="/tmp/ogb")

ヘテログラフと HeteroData

実世界の複雑な関係をモデル化する

多くの実世界のグラフは、複数種類のノードと複数種類の辺を持つヘテログラフ(異種グラフ)です。例えば、映画推薦システムでは「ユーザー」と「映画」という2種類のノードが、「視聴した」「高評価した」「プロデュースした」などの複数種類の辺で結ばれています。

PyG は HeteroData クラスでこうした構造を表現できます。

from torch_geometric.data import HeteroData

# 推薦グラフの例: ユーザー・映画・俳優の3種類ノード
hetero_data = HeteroData()

# 各ノードタイプの特徴量
hetero_data["user"].x = torch.randn(1000, 64)     # 1000 ユーザー
hetero_data["movie"].x = torch.randn(5000, 128)   # 5000 映画
hetero_data["actor"].x = torch.randn(10000, 32)   # 10000 俳優

# 辺タイプ: (送信ノードタイプ, 辺の種類, 受信ノードタイプ)
hetero_data["user", "watched", "movie"].edge_index = torch.randint(0, 5000, (2, 50000))
hetero_data["user", "rated", "movie"].edge_index = torch.randint(0, 5000, (2, 20000))
hetero_data["actor", "appeared_in", "movie"].edge_index = torch.randint(0, 5000, (2, 100000))

print(hetero_data)
# HeteroData(
#   user={ x=[1000, 64] },
#   movie={ x=[5000, 128] },
#   actor={ x=[10000, 32] },
#   (user, watched, movie)={ edge_index=[2, 50000] },
#   ...
# )
print(hetero_data.node_types)   # ['user', 'movie', 'actor']
print(hetero_data.edge_types)   # [('user', 'watched', 'movie'), ...]

ヘテログラフ用の GNN は to_homogeneous() で同種グラフに変換してから通常の Conv を使う方法と、PyG が提供するヘテロ専用の Conv(HeteroConv, HGTConv)を使う方法があります。

from torch_geometric.nn import HeteroConv, SAGEConv

# HeteroConv: 辺タイプごとに異なる Conv を適用
conv = HeteroConv({
    ("user", "watched", "movie"): SAGEConv(64, 64),
    ("actor", "appeared_in", "movie"): SAGEConv(32, 64),
}, aggr="sum")  # 複数辺タイプからの集約を和で結合

# 辺タイプごとに異なる変換が適用される
# out_dict = conv(x_dict, edge_index_dict)
# out_dict["movie"]: 映画ノードの更新された特徴量

HeteroConv は辺タイプ ("src_type", "relation", "dst_type") ごとに独立した Conv を持ち、同じ受信ノードへの異なる辺タイプからの集約を最後にまとめます。これにより「ユーザーが視聴した映画」と「俳優が出演した映画」からの情報を独立して処理できます。

リンク予測タスク

リンク予測は「2つのノード間に辺が存在するか」を予測するタスクです。友人関係の推薦(SNS)、知識グラフ補完、タンパク質間相互作用予測などに使われます。

PyG でのリンク予測の標準的なアプローチは次のとおりです。

from torch_geometric.utils import negative_sampling

# 正例: 実際に存在する辺
pos_edge_index = data.edge_index  # [2, E]

# 負例: ランダムにサンプリングした存在しない辺
neg_edge_index = negative_sampling(
    edge_index=data.edge_index,
    num_nodes=data.num_nodes,
    num_neg_samples=data.num_edges,  # 正例と同数の負例
)

def link_pred_loss(z, pos_edge_index, neg_edge_index):
    """ノード埋め込み z からリンク予測損失を計算"""
    # 正例のスコア: ノードiとノードjの埋め込みの内積
    pos_score = (z[pos_edge_index[0]] * z[pos_edge_index[1]]).sum(dim=-1)
    neg_score = (z[neg_edge_index[0]] * z[neg_edge_index[1]]).sum(dim=-1)

    # BCE 損失
    pos_loss = F.binary_cross_entropy_with_logits(pos_score, torch.ones_like(pos_score))
    neg_loss = F.binary_cross_entropy_with_logits(neg_score, torch.zeros_like(neg_score))
    return pos_loss + neg_loss

# GNN でノード埋め込みを取得し、内積でリンク存在確率を推定
# z = gnn_model(data.x, train_edge_index)  # 訓練辺のみ使う
# loss = link_pred_loss(z, pos_edge_index, neg_edge_index)

内積によるスコア計算は最もシンプルな方法で、2つのノードの埋め込みが近いほど辺が存在する確率が高いと予測します。より複雑な関係(知識グラフの論理的推論など)には RotatE や TransE のような関係専用のスコアリング関数が使われます。


まとめ

本記事では、PyTorch Geometric (PyG) のインストールから実用的な応用まで体系的に解説しました。

  • Data オブジェクト — グラフを x, edge_index, y などの属性で表現。edge_index は COO 形式の $[2, E]$ テンソル
  • メッセージパッシング — message(メッセージ生成)→ aggregate(集約)→ update(更新)の3ステップ
  • GCNConv — 次数正規化による安定した集約。小〜中規模のグラフに有効
  • GATConv — アテンションで重要な近傍を動的に選択。表現力が高い
  • SAGEConv — 帰納的学習で新規ノードに対応。大規模グラフに適する
  • GINConv — 和集約 + MLP による 1-WL 同等の最強表現力。グラフ分類に特に有効
  • DataLoader — 異なるサイズのグラフをバッチ化。batch テンソルでグラフ帰属を管理
  • NeighborLoader — 大規模グラフで近傍をサンプリングしてミニバッチ化。OOM を回避
  • グラフ分類 — Global Pooling でノード埋め込みをグラフ表現に集約。global_add_pool が GIN に最適
  • MessagePassing 継承 — 独自の集約関数・更新関数を定義できる拡張機能
  • 過平滑化 — GNN は 2〜3層が最適。深くすると特徴が均一化し、JK などで対策
  • Transforms — データ前処理を宣言的に指定。Compose で直列パイプライン化
  • 自作データセットInMemoryDataset(小規模)または Dataset(大規模)を継承
  • HeteroData — 複数種類のノード・辺を持つヘテログラフの表現と学習

PyG を習得したあとのステップとして、グラフ畳み込みの理論的背景(スペクトルグラフ理論)やアテンションの詳細(GAT のマルチヘッド機構)を理解するとより深い応用が可能になります。また、Open Graph Benchmark (OGB) の大規模データセットで実際に試すことで、NeighborLoader や ヘテログラフの効果を実感できます。以下の記事も参考にしてください。

【GNN】Message Passing Neural Network(MPNN)を解説する
グラフニューラルネットワークのメッセージパッシング理論を詳しく解説します
画像なし
【GNN】pytorch geometricでMessagePassingのNetworkを実装する
PyG の MessagePassing 基底クラスを使った GNN 実装の詳細を解説します

引用・参考文献

  • PyG 公式ドキュメント
  • Fey, M. & Lenssen, J. E. (2019). Fast Graph Representation Learning with PyTorch Geometric. ICLR Workshop.
  • Kipf, T. N. & Welling, M. (2017). Semi-Supervised Classification with Graph Convolutional Networks. ICLR.
  • Veličković, P. et al. (2018). Graph Attention Networks. ICLR.
  • Hamilton, W. L. et al. (2017). Inductive Representation Learning on Large Graphs. NeurIPS.