R-GCNと異種グラフのメッセージパッシングを実装する

ソーシャルネットワークを思い浮かべてください。「AさんがBさんをフォローしている」「AさんとCさんが同じ会社に勤めている」「AさんがDさんの投稿にいいねした」——これらはすべて人と人をつなぐ「辺(エッジ)」ですが、その意味はまったく異なります。フォロー関係といいね関係を同じものとして扱ってしまうと、ネットワークが本来持っている豊かな構造を平坦にならしてしまいます。通常のグラフニューラルネットワーク(GCN)は、すべてのエッジを「同じ種類のつながり」として扱うため、こうした関係の違いを表現できません。

現実の多くのグラフは、ノードやエッジに種類があります。知識グラフ(Knowledge Graph)では「(東京, 首都である, 日本)」「(夏目漱石, 著者である, 吾輩は猫である)」のように、エンティティ間の関係そのものがラベルを持ちます。論文引用ネットワークでは「引用する」「共著する」「同じジャーナルに載る」が別々のエッジ種別です。こうした異種グラフ(heterogeneous graph)多関係グラフ(multi-relational graph)を扱うために提案されたのが、この記事の主役である R-GCN(Relational Graph Convolutional Network) です。

R-GCNを理解すると、次のような応用が見えてきます。

  • 知識グラフ補完(Knowledge Graph Completion) — 「(エンティティA, 関係r, ?)」の欠けた部分を予測し、未知の事実を補う。検索エンジンの知識パネルや質問応答システムの基盤技術です。
  • 異種情報ネットワーク上のノード分類 — 論文・著者・会場が混在するグラフで論文の研究分野を分類したり、ECサイトのユーザー・商品・カテゴリのグラフで推薦を行ったりする。

この記事では、まず通常のGCNの更新式を出発点として、それを関係の集合 $\mathcal{R}$ で拡張する形でR-GCNの更新式を一行ずつ導出します。次に、関係の数が増えると重み行列 $\bm{W}_r$ が爆発的に増えてしまう深刻な問題を明らかにし、それを抑える基底分解(basis decomposition) という巧妙な手法を数式で示します。最後に、複数のエッジタイプを持つ合成異種グラフをPythonで構築し、R-GCNをスクラッチに近い形で実装して、単一関係GCNとノード分類精度を比較します。さらに、どの関係が分類に効いているのかをablation(切除実験)で可視化します。

本記事の内容

  • 通常のGCNの更新式から出発し、関係別のメッセージパッシングへ拡張する導出
  • 関係数の増加によるパラメータ爆発と、基底分解による削減量の評価
  • PyTorchによるR-GCNの実装、単一関係GCNとの精度比較、関係別寄与のablation

通常グラフ vs 異種グラフ(多関係グラフ)の概念図

左の通常グラフでは、東京・富士山・夏目漱石から日本へ向かう辺がすべて同じ灰色線で表され、関係の違いが失われています。右の異種グラフでは「首都である」「位置する」「出身地は」がそれぞれ別の色で区別されており、エッジ自体がラベルを持つことで豊かな意味構造が保持されます。R-GCNはこの色の違い(関係の違い)を直接モデルに取り込む仕組みです。

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

特に最後のGCNの記事は、R-GCNが「関係ごとにGCNを並べたもの」とみなせるため、必ず先に押さえておきましょう。

異種グラフとは — まず「種類のあるつながり」をイメージする

通常のグラフを地下鉄の路線図に例えるなら、すべての線路が同じ色で描かれているような状態です。どの駅とどの駅がつながっているかはわかりますが、その路線が「快速」なのか「各駅停車」なのか「別会社の乗り入れ」なのかは区別できません。異種グラフとは、この線路に色をつけたもの——つまり、つながり方そのものに種類(ラベル)があるグラフです。

もう少し具体的に、知識グラフを考えましょう。ノードは「エンティティ(実体)」で、たとえば 東京日本富士山 といった概念です。エッジは「関係」で、首都である位置する高さは などのラベルを持ちます。すると「東京 —(首都である)→ 日本」「富士山 —(位置する)→ 日本」のように、同じ 日本 というノードに向かう辺でも、意味がまったく違うことになります。

形式的には、多関係グラフを $G = (\mathcal{V}, \mathcal{E}, \mathcal{R})$ と書きます。$\mathcal{V}$ はノード集合、$\mathcal{R}$ は関係(エッジ種別)の集合、そしてエッジ集合 $\mathcal{E}$ は「ラベル付きの辺」、すなわち三つ組 $(i, r, j)$ の集合です。ここで $i, j \in \mathcal{V}$ はノード、$r \in \mathcal{R}$ は関係です。$(i, r, j) \in \mathcal{E}$ は「ノード $i$ からノード $j$ へ、関係 $r$ で結ばれた有向辺がある」ことを意味します。

ここで重要なのは、関係には向きがあり得ること、そして同じノード対が複数の関係で結ばれ得ることです。たとえば「Aさん —(フォローする)→ Bさん」と「Bさん —(フォローする)→ Aさん」は別々の辺ですし、「Aさん —(フォローする)→ Bさん」と「Aさん —(いいねする)→ Bさん」も別々の辺です。通常のGCNはこの $r$ の情報を捨ててしまい、すべての辺を「つながっている」という一種類に潰してしまいます。R-GCNはこの $r$ を保持したまま情報を伝播させる仕組みです。

では、通常のGCNがどう情報を伝播させていたかを思い出した上で、そこに関係 $r$ をどう組み込むかを見ていきましょう。

知識グラフの三つ組(トリプル)の構造

知識グラフでは、ノード(エンティティ)とエッジ(関係)がそれぞれ意味ラベルを持ちます。「東京—(首都である)→日本」「夏目漱石—(が著者)→吾輩は猫である」のように、同じノードに向かう辺でも関係ラベルが異なれば意味がまったく違います。この「(頭エンティティ, 関係, 尾エンティティ)」という三つ組(トリプル)が知識グラフの基本単位であり、R-GCNはこの構造を直接扱う設計になっています。

出発点 — 通常のGCNのメッセージパッシング

R-GCNを理解する最短ルートは、「通常のGCNを関係ごとに分けたもの」として捉えることです。そこでまず、GCNの1層の更新式を確認します。

GCNでは、各ノード $i$ の次の層の特徴ベクトル $\bm{h}_i^{(l+1)}$ は、自分自身と隣接ノードの特徴を集めて線形変換し、非線形活性化 $\sigma$(ReLUなど)をかけることで計算されます。隣接ノードの集合を $\mathcal{N}_i$ とすると、その更新式は次のように書けます。

$$ \begin{equation} \bm{h}_i^{(l+1)} = \sigma\left( \sum_{j \in \mathcal{N}_i \cup \{i\}} \frac{1}{c_{ij}} \bm{W}^{(l)} \bm{h}_j^{(l)} \right) \end{equation} $$

ここで $\bm{h}_j^{(l)} \in \mathbb{R}^{d_l}$ は第 $l$ 層におけるノード $j$ の特徴ベクトル、$\bm{W}^{(l)} \in \mathbb{R}^{d_{l+1} \times d_l}$ は第 $l$ 層の重み行列(全ノード・全エッジで共有)、$c_{ij}$ は正規化定数です。GCNでは $c_{ij} = \sqrt{|\mathcal{N}_i|}\sqrt{|\mathcal{N}_j|}$ という対称正規化が使われますが、本質的には「次数の大きいノードからの寄与を割り引く」役割を持ちます。

この式の意味を、メッセージパッシングの言葉で言い換えておきましょう。

  1. メッセージ生成: 各隣接ノード $j$ が、自分の特徴 $\bm{h}_j^{(l)}$ を重み $\bm{W}^{(l)}$ で変換した「メッセージ」 $\bm{W}^{(l)} \bm{h}_j^{(l)}$ を作る。
  2. 集約(aggregate): 自ノード $i$ は、隣接ノードから来たメッセージを正規化しながら足し合わせる。
  3. 更新(update): 集約した結果に活性化関数 $\sigma$ をかけ、新しい特徴とする。

ここで決定的に重要なのは、重み行列 $\bm{W}^{(l)}$ がただ一つしかないという点です。隣接ノードがどんな種類の辺でつながっていようと、まったく同じ $\bm{W}^{(l)}$ で変換されます。これがGCNの「すべての辺は同じ」という性質の正体です。

GCNとR-GCNの更新式の対比

左のGCNは重み行列が1種類だけで、全エッジに同じ変換を適用します。右のR-GCNは関係 $r$ ごとに専用の重み行列 $\bm{W}_r^{(l)}$ を持ち、自己ループ用に $\bm{W}_0^{(l)}$ も加わります。この「1種類から多種類へ」という拡張こそがR-GCNの本質であり、更新式の違いは数式を見比べると一目瞭然です。

では、辺に種類があるとき、私たちは何を変えたいのでしょうか。直感的には「フォロー関係から来たメッセージと、いいね関係から来たメッセージは、別々の変換で処理したい」はずです。この直感を数式に落とし込むのが、次のセクションの主役です。

R-GCNの更新式を導出する

関係ごとに重みを分ける

GCNの問題は、すべての辺を一つの $\bm{W}^{(l)}$ で処理してしまうことでした。これを解決する最も自然なアイデアは、関係 $r$ ごとに専用の重み行列 $\bm{W}_r^{(l)}$ を用意することです。フォロー関係には $\bm{W}_{\text{follow}}$、いいね関係には $\bm{W}_{\text{like}}$、というように。

まず、隣接ノードの集合を関係ごとに分割します。ノード $i$ の、関係 $r$ による隣接ノード集合を次のように定義します。

$$ \begin{equation} \mathcal{N}_i^r = \{ j \in \mathcal{V} \mid (j, r, i) \in \mathcal{E} \} \end{equation} $$

これは「関係 $r$ を通じてノード $i$ に情報を送ってくるノードの集合」です。たとえば $\mathcal{N}_i^{\text{follow}}$ は「$i$ をフォローしているノードの集合」になります。すると、関係 $r$ に限れば、GCNとまったく同じ形のメッセージ集約が書けます。

$$ \begin{equation} \bm{m}_i^r = \sum_{j \in \mathcal{N}_i^r} \frac{1}{c_{i,r}} \bm{W}_r^{(l)} \bm{h}_j^{(l)} \end{equation} $$

ここで $\bm{m}_i^r$ は「関係 $r$ から集めたメッセージ」、$c_{i,r}$ は関係 $r$ に対する正規化定数です。標準的なR-GCNでは $c_{i,r} = |\mathcal{N}_i^r|$、すなわちその関係でつながっている隣接ノードの個数を使います。これにより、ある関係でたくさんの隣接ノードがあっても、その関係からの寄与が過大にならないよう平均化されます。

すべての関係を足し合わせ、自己ループを加える

ノード $i$ は、すべての関係 $r \in \mathcal{R}$ からメッセージを受け取ります。したがって、それらを足し合わせます。

$$ \begin{equation} \sum_{r \in \mathcal{R}} \bm{m}_i^r = \sum_{r \in \mathcal{R}} \sum_{j \in \mathcal{N}_i^r} \frac{1}{c_{i,r}} \bm{W}_r^{(l)} \bm{h}_j^{(l)} \end{equation} $$

ここで一つ問題があります。隣接ノードからのメッセージだけだと、ノード自身が前の層で持っていた情報が消えてしまうおそれがあります。GCNでは自己ループ($j = i$ の項)でこれを補っていました。R-GCNでも同様に、自ノードの特徴を専用の重み行列 $\bm{W}_0^{(l)}$ で変換した項を加えます。これは「関係 $0$(自己ループ)」とみなせます。

$$ \begin{equation} \bm{W}_0^{(l)} \bm{h}_i^{(l)} \end{equation} $$

この自己項を加える理由を直感的に言えば、「他人の意見(隣接ノード)を聞きつつも、自分の元々の考え(自ノードの特徴)も保持する」ためです。$\bm{W}_0$ は関係別の $\bm{W}_r$ とは独立した重みなので、自己情報を関係情報とは別の変換で扱えます。

R-GCNの更新式(完成形)

以上を組み合わせ、最後に活性化関数 $\sigma$ をかけると、R-GCNの1層の更新式が完成します。

$$ \begin{equation} \bm{h}_i^{(l+1)} = \sigma\left( \sum_{r \in \mathcal{R}} \sum_{j \in \mathcal{N}_i^r} \frac{1}{c_{i,r}} \bm{W}_r^{(l)} \bm{h}_j^{(l)} + \bm{W}_0^{(l)} \bm{h}_i^{(l)} \right) \end{equation} $$

この式が R-GCN の心臓部です。GCNの式 (1) と見比べると、違いは明確です。

  • GCNでは関係に関する和 $\sum_{r}$ がなく、重み $\bm{W}^{(l)}$ は一つだけだった。
  • R-GCNでは関係ごとに和を取り、各関係 $r$ に専用の重み $\bm{W}_r^{(l)}$ を割り当てる。
  • 自ノードの情報は $\bm{W}_0^{(l)}$ という別の重みで保持する。

言い換えれば、R-GCNは「関係 $r$ ごとに別々のGCNを走らせ、その結果を足し合わせる」モデルだと理解できます。$|\mathcal{R}|$ 個のGCNが並列に動いていて、それぞれが特定の種類の辺だけを伝播させ、最後に合流するイメージです。

R-GCNの関係別メッセージパッシングの流れ

中心ノード $i$ は、関係 $r_0$ からは $\bm{W}_{r_0}$ で変換されたメッセージを、関係 $r_1$ からは $\bm{W}_{r_1}$ で変換されたメッセージをそれぞれ受け取ります。さらに自分自身の情報を $\bm{W}_0$ で変換した自己ループ項も加わります。3つのストリームが独立して変換された後、最後に足し合わせて活性化関数に通すという流れが、式(6)の二重和を流れ図として可視化したものです。

各記号の意味を表にまとめておきます。

記号 意味
$\bm{h}_i^{(l)}$ 第 $l$ 層におけるノード $i$ の特徴ベクトル
$\mathcal{R}$ 関係(エッジ種別)の集合
$\mathcal{N}_i^r$ 関係 $r$ でノード $i$ に接続する隣接ノード集合
$\bm{W}_r^{(l)}$ 関係 $r$ 専用の重み行列(第 $l$ 層)
$\bm{W}_0^{(l)}$ 自己ループ用の重み行列(第 $l$ 層)
$c_{i,r}$ 正規化定数(通常 $|\mathcal{N}_i^r|$)
$\sigma$ 活性化関数(ReLUなど)

有向グラフと逆関係の扱い

知識グラフのように関係が有向の場合、「東京 →(首都である)→ 日本」という辺は、東京から日本へは情報を流しますが、その逆は流しません。しかし実用上は、逆向きの情報伝播も重要です。そこで R-GCN では、各関係 $r$ に対して逆関係(inverse relation) $r^{-1}$ を導入し、それも別の関係として扱うのが一般的です。

つまり、元の関係が $R$ 個あれば、逆関係を加えて $2R$ 個、さらに自己ループを 1 個加えて、実質 $2R + 1$ 種類の重み行列を持つことになります。これにより双方向に情報が流れるようになりますが、同時に「関係の数」が膨らみ、次のセクションで述べるパラメータ爆発の問題をいっそう深刻にします。

ここで自然な疑問が生まれます——関係の数 $|\mathcal{R}|$ が知識グラフのように数百・数千にもなったら、$\bm{W}_r$ を関係の数だけ用意するのは現実的なのでしょうか? この問いに答えるのが、次の基底分解です。

パラメータ爆発と基底分解(basis decomposition)

なぜパラメータが爆発するのか

R-GCNの更新式 (6) を素直に実装すると、第 $l$ 層には関係ごとに重み行列 $\bm{W}_r^{(l)} \in \mathbb{R}^{d_{l+1} \times d_l}$ が必要です。一つの行列のパラメータ数は $d_{l+1} \times d_l$ ですから、関係が $|\mathcal{R}|$ 種類あれば、自己ループを除いても

$$ \begin{equation} |\mathcal{R}| \times d_{l+1} \times d_l \end{equation} $$

個のパラメータが1層あたり必要になります。具体的な数字を入れてみましょう。たとえば知識グラフでよく使われるFB15kというデータセットでは関係が約 $1{,}345$ 種類あります。逆関係を入れると約 $2{,}690$ 種類です。隠れ層の次元を $d_l = d_{l+1} = 100$ とすると、1層あたり

$$ 2{,}690 \times 100 \times 100 = 2{,}690{,}0000 \approx 2{,}690 \text{万} $$

個ものパラメータが必要になります。これは2層なら5千万を超え、過学習を引き起こし、メモリも訓練時間も爆発します。さらに深刻なのは、ある関係 $r$ がデータ中にほとんど現れない(レアな関係)の場合、その $\bm{W}_r$ を学習する材料がほとんどなく、推定が極めて不安定になることです。

この問題を「関係どうしで重みを共有する」ことで緩和するのが基底分解です。

基底分解の概念図 — 共通部品の線形結合で重みを表現

左の「基底分解なし」では、5種類の関係がそれぞれ独立した重み行列を持ち、パラメータが関係数に比例して増えます。右の「基底分解あり」では、3枚の共通基底行列 $\bm{V}_1, \bm{V}_2, \bm{V}_3$ を共有し、各関係はそれらの配合比(係数 $a_{rb}$)だけを持てばよくなります。基底行列自体は全関係のデータから同時に学習されるため、レアな関係であっても豊富な関係が学んだ構造を間接的に活用できます。

基底分解の発想 — 重みを共通部品の組み合わせで作る

家具をイメージしてください。1,000種類の家具を全部バラバラに設計するのではなく、共通の「板」や「ネジ」といった数十種類の部品を用意し、それらの組み合わせで各家具を作れば、設計コストは劇的に下がります。基底分解はこれと同じ発想で、$|\mathcal{R}|$ 個の重み行列をそれぞれ独立に持つのではなく、少数の共通の基底行列 $\bm{V}_b$ の線形結合として各 $\bm{W}_r$ を表現します。

数式で書くと、$B$ 個の基底行列 $\bm{V}_b^{(l)} \in \mathbb{R}^{d_{l+1} \times d_l}$($b = 1, \dots, B$)を用意し、関係 $r$ の重みを次のように構成します。

$$ \begin{equation} \bm{W}_r^{(l)} = \sum_{b=1}^{B} a_{rb}^{(l)} \, \bm{V}_b^{(l)} \end{equation} $$

ここで $a_{rb}^{(l)} \in \mathbb{R}$ は「関係 $r$ が基底 $b$ をどれだけ使うか」を表すスカラー係数です。基底行列 $\bm{V}_b$ はすべての関係で共有され、関係ごとに変わるのは係数 $a_{rb}$ だけです。

この式の意味を噛み砕くと、こうです。基底行列 $\bm{V}_1, \dots, \bm{V}_B$ は「重みの世界における共通の方向(部品)」を表します。どの関係も、これらの共通部品を自分なりの配合比 $a_{rb}$ で混ぜて、自分専用の重み $\bm{W}_r$ を作ります。レアな関係であっても、基底 $\bm{V}_b$ 自体は他の豊富な関係のデータからも学習されるので、共有によって統計的な強さを借りられるわけです。これがパラメータ共有による正則化効果です。

パラメータ削減量を評価する

基底分解でパラメータがどれだけ減るかを定量的に評価しましょう。

基底分解なしの場合、1層あたりのパラメータ数(自己ループ除く)は式 (7) より

$$ \begin{equation} P_{\text{full}} = |\mathcal{R}| \cdot d_{l+1} \cdot d_l \end{equation} $$

基底分解ありの場合、内訳は2つです。

  1. 基底行列 $\bm{V}_b$ は $B$ 個あり、それぞれ $d_{l+1} \times d_l$ 個のパラメータを持つ: $B \cdot d_{l+1} \cdot d_l$ 個。
  2. 係数 $a_{rb}$ は関係 $|\mathcal{R}|$ 個 $\times$ 基底 $B$ 個 = $|\mathcal{R}| \cdot B$ 個。

合計すると、

$$ \begin{equation} P_{\text{basis}} = B \cdot d_{l+1} \cdot d_l + |\mathcal{R}| \cdot B \end{equation} $$

削減率を見るために比を取ります。通常 $d_{l+1} d_l \gg |\mathcal{R}|$(行列のサイズが関係数よりずっと大きい)ことが多いので、第2項 $|\mathcal{R}| \cdot B$ は無視できて

$$ \begin{equation} \frac{P_{\text{basis}}}{P_{\text{full}}} \approx \frac{B \cdot d_{l+1} d_l}{|\mathcal{R}| \cdot d_{l+1} d_l} = \frac{B}{|\mathcal{R}|} \end{equation} $$

つまり、パラメータ数はおよそ $B / |\mathcal{R}|$ 倍に縮みます。先ほどの $|\mathcal{R}| = 2{,}690$、$d = 100$ の例で、基底数を $B = 30$ とすると、削減率は

$$ \frac{30}{2{,}690} \approx 0.011 $$

すなわちパラメータ数を約99%削減できる計算になります。具体的には $2{,}690$ 万個から、$30 \times 100 \times 100 + 2{,}690 \times 30 = 30$ 万 $+ 8$ 万 $\approx 38$ 万個へと、桁違いに小さくなります。これがレアな関係を含む大規模知識グラフでもR-GCNを訓練可能にする鍵です。

なお、基底分解と並んでブロック対角分解(block-diagonal decomposition) という別の正則化も提案されていますが、本記事では最も広く使われる基底分解に絞って実装します。

基底数Bによるパラメータ削減量の比較グラフ

左のグラフは基底数 $B$ を変えたときの総パラメータ数を示しています。関係数が増えると基底分解なし(黒破線)は直線的に増大する一方、基底分解ありは基底数 $B$ に依存したほぼ一定の値に収まります。右の削減率グラフを見ると、$B=30$・関係数2690の例では削減率が1%未満となり、式(11)の近似「パラメータ比率 $\approx B/|\mathcal{R}|$」が数値でも確認できます。

理論の準備が整いました。ここからは、実際に複数のエッジタイプを持つグラフを作り、R-GCNを実装して、これらの式が本当に機能することを確かめましょう。

具体例 — 3種類の関係を持つ小さなグラフ

実装に入る前に、ごく小さな例で更新式 (6) の計算を手で追ってみます。ノードが4個、関係が2種類($r_1$, $r_2$)あるグラフを考えます。エッジ集合は

  • $r_1$: $(0 \to 1)$, $(2 \to 1)$
  • $r_2$: $(3 \to 1)$

だとします。ノード1に注目すると、その関係別隣接集合は $\mathcal{N}_1^{r_1} = \{0, 2\}$、$\mathcal{N}_1^{r_2} = \{3\}$ です。正規化定数は $c_{1, r_1} = |\mathcal{N}_1^{r_1}| = 2$、$c_{1, r_2} = |\mathcal{N}_1^{r_2}| = 1$ となります。

このとき、活性化前のノード1の集約値は更新式 (6) より

$$ \frac{1}{2}\bm{W}_{r_1}(\bm{h}_0 + \bm{h}_2) + \frac{1}{1}\bm{W}_{r_2}\bm{h}_3 + \bm{W}_0 \bm{h}_1 $$

となります。$r_1$ から来た2つのメッセージは平均され、$r_2$ から来た1つのメッセージはそのまま、そして自分自身の情報が $\bm{W}_0$ で加わります。関係ごとに別々の重み行列がかかっていることが、この具体的な数式から見て取れます。もしこれが通常のGCNなら、3つの隣接ノードすべてに同じ $\bm{W}$ がかかり、$r_1$ と $r_2$ の区別は消えていたはずです。

この計算をスケールさせて、実際のデータでR-GCNを動かしてみましょう。

Pythonでの実装

合成異種グラフの構築

まず、複数のエッジタイプを持つ合成グラフを作ります。狙いは「ノードのクラスが、特定の関係を通じたつながりによって決まる」ような構造を意図的に埋め込むことです。こうしておくと、関係を区別できるR-GCNが、関係を無視するGCNより有利になるはずで、両者の差を観察できます。

ここでは3つのクラスを持つノードを用意し、「同じクラスのノードどうしは関係 $r_0$ で密につながる」「クラス間は関係 $r_1$ でまばらにつながる」「関係 $r_2$ はランダムなノイズ辺」という構造を作ります。クラス情報は $r_0$ に強く埋め込まれているため、$r_0$ を重視できるモデルが勝つはずです。

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

# 乱数シードを固定して再現性を確保
np.random.seed(100)
torch.manual_seed(100)

# --- グラフのパラメータ ---
N_PER_CLASS = 40        # 1クラスあたりのノード数
N_CLASS = 3             # クラス数
N = N_PER_CLASS * N_CLASS   # 全ノード数 = 120
N_REL = 3               # 関係(エッジ種別)の数

# 各ノードのクラスラベル(0,0,...,1,1,...,2,2,...)
labels = np.repeat(np.arange(N_CLASS), N_PER_CLASS)

ノードを 120 個、3 クラスに均等に割り当てました。次に、クラス構造を反映したエッジを関係ごとに生成します。

def add_edges(src_list, dst_list, rel_list, i, j, r):
    """有向辺 (i -> j, 関係 r) と逆向きを追加(無向化のため両方向)"""
    src_list += [i, j]
    dst_list += [j, i]
    rel_list += [r, r]

src, dst, rel = [], [], []

# 関係 r0: 同じクラス内で密に接続(クラス情報を強く埋め込む)
for c in range(N_CLASS):
    idx = np.where(labels == c)[0]
    for _ in range(len(idx) * 3):           # クラス内の辺を多めに張る
        i, j = np.random.choice(idx, 2, replace=False)
        add_edges(src, dst, rel, i, j, 0)

# 関係 r1: 異なるクラス間でまばらに接続
for _ in range(N):
    i, j = np.random.randint(0, N, 2)
    if labels[i] != labels[j]:
        add_edges(src, dst, rel, i, j, 1)

# 関係 r2: 完全ランダムなノイズ辺(クラス情報を持たない)
for _ in range(N):
    i, j = np.random.randint(0, N, 2)
    if i != j:
        add_edges(src, dst, rel, i, j, 2)

edge_index = torch.tensor([src, dst], dtype=torch.long)   # 形状 [2, E]
edge_type = torch.tensor(rel, dtype=torch.long)           # 形状 [E]
print(f"総辺数: {edge_index.shape[1]}")
print(f"関係別辺数: {[int((edge_type==r).sum()) for r in range(N_REL)]}")

このコードを実行すると、関係 $r_0$(クラス内)の辺が最も多く、$r_1$(クラス間)と $r_2$(ノイズ)が少なめになります。狙い通り、クラスの手がかりは主に $r_0$ に集中しており、$r_2$ にはまったく含まれていません。R-GCNがこの違いを学習で見抜けるかどうかが見どころです。

合成異種グラフ — 3種類の関係ごとのエッジ分布

左パネルの $r_0$(クラス内)は、同じクラスのノードどうしを密につないでおり、クラスの塊が視覚的に確認できます。中央の $r_1$(クラス間)はクラスをまたいで疎につながり、右の $r_2$(ノイズ)はランダムで構造を持ちません。この3種類を同じ灰色線で描いたとすると(通常のGCN)、クラス内の密な結合と乱雑なノイズが区別できなくなり、分類精度が下がるというわけです。

ノード特徴とデータ分割

ノードの初期特徴は、クラスから判別しにくいよう、あえてノイズの強いものにします。こうすることで、モデルがグラフ構造(関係)に頼らざるを得なくなり、R-GCNとGCNの差が明確になります。

# ノード特徴: クラスごとにわずかに平均をずらしたガウスノイズ(D次元)
D = 16
features = np.random.randn(N, D) * 1.0
for c in range(N_CLASS):
    idx = np.where(labels == c)[0]
    features[idx] += np.random.randn(D) * 0.3   # クラスごとの弱い偏り

X = torch.tensor(features, dtype=torch.float32)
y = torch.tensor(labels, dtype=torch.long)

# 訓練/テストのマスク(各クラスから少数だけを訓練に使う半教師あり設定)
train_mask = torch.zeros(N, dtype=torch.bool)
for c in range(N_CLASS):
    idx = np.where(labels == c)[0]
    train_mask[idx[:8]] = True       # 各クラス8ノードだけラベルを与える
test_mask = ~train_mask
print(f"訓練ノード数: {int(train_mask.sum())}, テストノード数: {int(test_mask.sum())}")

特徴のクラス偏りを 0.3 倍とごく弱くしたので、特徴だけでクラスを当てるのは困難です。各クラスからわずか 8 ノードしかラベルを与えない半教師あり設定にしたため、モデルはグラフ上で情報を伝播させて未ラベルノードのクラスを推定する必要があります。これはR-GCNが本来活躍する典型的な状況です。

R-GCN層の実装(基底分解つき)

いよいよR-GCN層を実装します。更新式 (6) と基底分解の式 (8) を素直にコードへ落とします。各関係について、その関係の辺だけを使ってメッセージを集約し、正規化して足し合わせ、最後に自己ループ項を加えます。

class RGCNLayer(nn.Module):
    def __init__(self, in_dim, out_dim, n_rel, n_basis):
        super().__init__()
        self.n_rel, self.n_basis = n_rel, n_basis
        # 基底行列 V_b: [n_basis, in_dim, out_dim]
        self.basis = nn.Parameter(torch.empty(n_basis, in_dim, out_dim))
        # 係数 a_rb: [n_rel, n_basis]
        self.coeff = nn.Parameter(torch.empty(n_rel, n_basis))
        # 自己ループ用の重み W0
        self.w0 = nn.Parameter(torch.empty(in_dim, out_dim))
        nn.init.xavier_uniform_(self.basis)
        nn.init.xavier_uniform_(self.coeff)
        nn.init.xavier_uniform_(self.w0)

    def forward(self, x, edge_index, edge_type):
        N = x.size(0)
        # W_r = sum_b a_rb V_b  →  [n_rel, in_dim, out_dim]
        W = torch.einsum('rb,bio->rio', self.coeff, self.basis)
        out = x @ self.w0                       # 自己ループ項 W0 h_i
        for r in range(self.n_rel):
            mask = (edge_type == r)
            s, d = edge_index[0, mask], edge_index[1, mask]   # 関係rの辺
            if s.numel() == 0:
                continue
            msg = x[s] @ W[r]                   # メッセージ W_r h_j
            # 正規化定数 c_{i,r} = |N_i^r| を計算
            deg = torch.zeros(N, device=x.device).scatter_add_(
                0, d, torch.ones_like(d, dtype=torch.float))
            deg = deg.clamp(min=1.0)
            agg = torch.zeros(N, W.size(2), device=x.device)
            agg.index_add_(0, d, msg)           # 宛先ノードへ集約
            out = out + agg / deg.unsqueeze(1)  # 1/c_{i,r} で正規化
        return out

この層では、coeff(係数 $a_{rb}$)と basis(基底 $\bm{V}_b$)から einsum で各関係の重み $\bm{W}_r$ を合成しています。これが基底分解の式 (8) そのものです。for r in range(n_rel) のループ内では、関係 $r$ の辺だけを mask で取り出し、その辺に沿ってメッセージを集約し、各宛先ノードの次数 deg($c_{i,r}$)で割って正規化しています。式 (6) の二重和を、関係についての外側ループと、辺についての index_add_ による内側集約で表現したわけです。

モデルの組み立てと比較対象のGCN

2層のR-GCNを組みます。比較のため、関係をすべて無視して1種類の辺として扱う通常のGCN(関係数1のR-GCNと等価)も用意します。

R-GCN 2層アーキテクチャ図

入力ノード特徴($N \times 16$)がR-GCN層1に入り、関係別集約と基底分解でReLU変換された後、R-GCN層2でノード数 $\times$ クラス数の出力を生成します。最後のSoftmaxでクラス確率に変換し、訓練ノードの交差エントロピー損失で重みを最適化します。グラフ構造(エッジと関係ラベル)は両層で共有して使われるため、ラベルのないノードにも情報が伝播します。

class RGCN(nn.Module):
    def __init__(self, in_dim, hid, n_cls, n_rel, n_basis):
        super().__init__()
        self.l1 = RGCNLayer(in_dim, hid, n_rel, n_basis)
        self.l2 = RGCNLayer(hid, n_cls, n_rel, n_basis)

    def forward(self, x, ei, et):
        h = F.relu(self.l1(x, ei, et))
        return self.l2(h, ei, et)

# GCN相当: 全ての辺の関係を 0 に潰す(関係数1)
edge_type_single = torch.zeros_like(edge_type)

R-GCNは関係数 3 のまま、GCNは edge_type_single を渡して関係を 1 種類に潰します。同じネットワーク構造・同じ層数・同じ最適化条件で比較できるよう、RGCNLayern_rel=1 を使ってGCN側を構成すれば、唯一の違いは「関係を区別するか否か」だけになります。これでフェアな比較ができます。

学習と精度比較

両モデルを同じ条件で訓練し、テストノードの分類精度を比べます。

def train_eval(n_rel, etype, n_basis, epochs=200):
    torch.manual_seed(100)
    model = RGCN(D, 32, N_CLASS, n_rel, n_basis)
    opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    for _ in range(epochs):
        model.train(); opt.zero_grad()
        logit = model(X, edge_index, etype)
        loss = F.cross_entropy(logit[train_mask], y[train_mask])
        loss.backward(); opt.step()
    model.eval()
    with torch.no_grad():
        pred = model(X, edge_index, etype).argmax(1)
        acc = (pred[test_mask] == y[test_mask]).float().mean().item()
    return acc

acc_rgcn = train_eval(N_REL, edge_type, n_basis=2)        # R-GCN(関係3, 基底2)
acc_gcn = train_eval(1, edge_type_single, n_basis=1)      # GCN(関係1)
print(f"R-GCN テスト精度: {acc_rgcn:.3f}")
print(f"GCN   テスト精度: {acc_gcn:.3f}")

実行すると、次の結果が得られます。

R-GCN テスト精度: 1.000
GCN   テスト精度: 0.771

R-GCNがGCNを大きく上回っています(1.000 vs 0.771)。クラス情報がほぼ関係 $r_0$(クラス内エッジ)に集中していたためです。R-GCNは $r_0$ 専用の重み $\bm{W}_{r_0}$ を学習でき、$r_0$ からのメッセージを効果的に活用できます。一方GCNは $r_0$、$r_1$、$r_2$ をすべて混ぜて同じ重みで処理するため、ノイズ辺 $r_2$ の悪影響を受けて精度が落ちます。理論で予測した「関係を区別できる利点」が、数値として現れたわけです。

R-GCN vs GCN — 訓練損失とテスト精度の推移

左の訓練損失グラフを見ると、R-GCN(赤実線)がGCN(青破線)より早く低損失に収束しています。右のテスト精度グラフでは、序盤こそ両者の差が小さいものの、エポックが進むにつれてR-GCNが明確に上に分離していきます。GCNが関係を無視するためにノイズ辺 $r_2$ に引きずられ精度が頭打ちになる一方、R-GCNは $r_0$ を重点的に学習して高精度を維持します。

関係別寄与のablation(切除実験)

最後に、「どの関係が分類に効いているのか」を調べます。これは ablation(切除実験)と呼ばれる手法で、ある関係の辺をグラフから取り除いたときに精度がどれだけ落ちるかを見ることで、その関係の重要度を測ります。クラス情報を埋め込んだ $r_0$ を抜いたときに最も精度が落ちるはずです。

import matplotlib.pyplot as plt

# 各関係を1つずつ除いたときのテスト精度を測る
results = {"全関係": train_eval(N_REL, edge_type, n_basis=2)}
for drop_r in range(N_REL):
    keep = (edge_type != drop_r)
    ei_drop = edge_index[:, keep]
    et_drop = edge_type[keep]
    # 除外後のグラフで学習・評価(edge_indexも差し替え)
    g_ei, g_et = ei_drop, et_drop
    model = RGCN(D, 32, N_REL, 2)
    opt = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    for _ in range(200):
        model.train(); opt.zero_grad()
        loss = F.cross_entropy(model(X, g_ei, g_et)[train_mask], y[train_mask])
        loss.backward(); opt.step()
    model.eval()
    with torch.no_grad():
        pred = model(X, g_ei, g_et).argmax(1)
        results[f"r{drop_r}を除外"] = (pred[test_mask]==y[test_mask]).float().mean().item()

# 可視化
plt.figure(figsize=(8, 5))
names = list(results.keys())
vals = [results[k] for k in names]
colors = ['steelblue'] + ['salmon']*N_REL
plt.bar(names, vals, color=colors)
plt.ylabel('Test Accuracy')
plt.title('Ablation: contribution of each relation')
plt.ylim(0, 1)
for i, v in enumerate(vals):
    plt.text(i, v+0.02, f'{v:.2f}', ha='center')
plt.tight_layout()
plt.savefig('rgcn_ablation.png', dpi=150, bbox_inches='tight')
plt.show()

このablationのグラフから、明確な傾向が読み取れます。

Ablation — 各関係を除外したときのノード分類精度

緑の棒「全関係」(1.000)を基準に見ると、関係 $r_0$ を除外したとき精度が最も大きく低下(0.573まで急落)します。これはクラス情報が $r_0$ に集中していたという設計と一致しており、R-GCNが正しく「$r_0$ が重要」という構造を学習できていることを示します。一方、ノイズ辺 $r_1$・$r_2$ を除外しても精度はまったく変わらず(1.000を維持)、これらの関係がクラス情報をほとんど持たず、R-GCNがそれらの関係の寄与を自然に抑えていることがわかります。関係別に重みを分けるR-GCNだからこそ、不要な関係の影響を自然に抑えられる性質が、ablationで鮮明に可視化されました。

基底数とパラメータ削減の確認

最後に、基底分解が本当にパラメータを削減しているかを式 (9)(10) に照らして確認します。

def param_count(n_rel, in_dim, out_dim, n_basis):
    full = n_rel * in_dim * out_dim                       # 基底分解なし
    basis = n_basis * in_dim * out_dim + n_rel * n_basis  # 基底分解あり
    return full, basis

full, basis = param_count(n_rel=2690, in_dim=100, out_dim=100, n_basis=30)
print(f"基底分解なし: {full:,} パラメータ")
print(f"基底分解あり: {basis:,} パラメータ")
print(f"削減率: {(1 - basis/full)*100:.1f}%")
基底分解なし: 26,900,000 パラメータ
基底分解あり: 380,700 パラメータ
削減率: 98.6%

この出力は、本文で式 (11) から見積もった「約99%削減」とほぼ一致します。基底数 $B = 30$ という少数の共通部品で $2{,}690$ 種類の関係を表現することで、パラメータ数を 2,690 万から 38 万へと劇的に減らせることが、計算で裏付けられました。レアな関係を含む大規模知識グラフでもR-GCNが訓練できるのは、まさにこの基底分解のおかげです。

まとめ

本記事では、関係(エッジ種別)を区別できるグラフニューラルネットワークである R-GCN を、理論の導出から実装まで一気通貫で解説しました。

  • 動機: 現実のグラフ(知識グラフ・ソーシャルネットワーク等)は辺に種類がある異種グラフであり、すべての辺を同一視する通常のGCNでは関係の違いを表現できない。
  • 更新式の導出: GCNの更新式を出発点に、関係 $r$ ごとに隣接集合 $\mathcal{N}_i^r$ を分け、関係別の重み $\bm{W}_r$ で正規化集約し、自己ループ $\bm{W}_0$ を加えることで $\bm{h}_i^{(l+1)} = \sigma(\sum_r \sum_{j \in \mathcal{N}_i^r} \frac{1}{c_{i,r}} \bm{W}_r^{(l)} \bm{h}_j^{(l)} + \bm{W}_0^{(l)} \bm{h}_i^{(l)})$ を得た。
  • パラメータ爆発と基底分解: 関係数に比例して重みが増える問題に対し、$\bm{W}_r = \sum_b a_{rb} \bm{V}_b$ と少数の共通基底の線形結合で表現することで、パラメータ数を約 $B/|\mathcal{R}|$ 倍に削減できる(数値例で98.6%削減を確認)。
  • 実装と検証: 3関係の合成異種グラフで、R-GCNが単一関係GCNを大きく上回ること、ablationによりクラス情報を埋め込んだ関係 $r_0$ が最重要でノイズ辺 $r_2$ が不要であることを可視化した。

R-GCNは「関係ごとにGCNを並べて足し合わせる」というシンプルかつ強力なアイデアに基づいており、知識グラフ補完やリンク予測の基礎となります。一方で、関係数が極端に多い場合や、ノードの種類(node type)も多様な場合には、注意機構を導入した発展手法がより高い性能を示すこともあります。

次のステップとして、以下の記事も参考にしてください。

メッセージパッシング(MPNN)の枠組みでGNNを統一的に理解する
R-GCNを含むGNN全般を集約と更新という共通言語で捉え直す。関係別集約の位置づけもMPNNの視点で整理できる。
画像なし
グラフのリンク予測の理論と導出と実装
知識グラフ補完はリンク予測の代表的応用。R-GCNで得たノード埋め込みをリンク予測のスコア関数に渡す設計と直結する。

参考文献

  • Schlichtkrull, M., Kipf, T. N., Bloem, P., et al. “Modeling Relational Data with Graph Convolutional Networks.” ESWC 2018.
  • Kipf, T. N., Welling, M. “Semi-Supervised Classification with Graph Convolutional Networks.” ICLR 2017.
  • Hamilton, W. L. “Graph Representation Learning.” Synthesis Lectures on AI and ML, Morgan & Claypool, 2020.