LoginSignup
4
2

More than 1 year has passed since last update.

PyG (PyTorch Geometric) で MetaPath2Vec して Node2Vec と比較

Last updated at Posted at 2022-08-09

グラフ構造を深層学習する PyG (PyTorch Geometric) を Google Colaboratory 上で使ってみました。今回は、MetaPath2Vec を使うことがテーマです。Node2Vecとの比較も行ないました。

PyG (PyTorch Geometric) インストール

PyG (PyTorch Geometric) のレポジトリは https://github.com/pyg-team/pytorch_geometric にあります。また、コードはチュートリアルドキュメント https://pytorch-geometric.readthedocs.io/en/latest/index.html を参考にしています。

import os
import torch

torch.manual_seed(0)
os.environ['TORCH'] = torch.__version__
print(torch.__version__)

!pip install -q torch-scatter -f https://data.pyg.org/whl/torch-${TORCH}.html
!pip install -q torch-sparse -f https://data.pyg.org/whl/torch-${TORCH}.html
!pip install -q git+https://github.com/pyg-team/pytorch_geometric.git

import torch_geometric
1.12.0+cu113
[K     |████████████████████████████████| 7.9 MB 56.3 MB/s 
[K     |████████████████████████████████| 3.5 MB 27.1 MB/s 
[?25h  Building wheel for torch-geometric (setup.py) ... [?25l[?25hdone
device = 'cuda' if torch.cuda.is_available() else 'cpu'

データセットの自作

今回もデータセットを自作します。自作する理由は、結果の良し悪しを自分の目で判断しやすくするためです。

小さなデータで説明

A, B, C の3タイプのノード(頂点)があるものとします。

import numpy as np

num = 3

A = np.random.rand(num * 2)
A.sort()
B = np.random.rand(num * 4)
B.sort()
C = np.random.rand(num)
C.sort()

次のような基準を満たすときに、異なるタイプのノード間にのみエッジ(辺)が作られるものとします。

def get_edges(top, X, Y):
    edges = []

    for i1, n1 in enumerate(X):
        for i2 in np.argsort([np.abs(n1 - n2) for n2 in Y])[:top]:
            edge = [i1, i2]
            if edge not in edges:
                edges.append(edge)

    for i1, n1 in enumerate(Y):
        for i2 in np.argsort([np.abs(n1 - n2) for n2 in X])[:top]:
            edge = [i2, i1]
            if edge not in edges:
                edges.append(edge)

    edges.sort()
    return edges

このようにして、タイプAのノードとタイプBのノードの間、タイプBのノードとタイプCのノードの間にのみエッジが作られるものとします。タイプAのノードとタイプCのノードの間にはエッジは作りません。また、同じタイプのノード(タイプA同士、タイプB同士、タイプC同士)間にはエッジは作りません。

edges_AB = get_edges(2, A, B)
edges_BC = get_edges(2, B, C)
edges_BA = get_edges(2, B, A)
edges_CB = get_edges(2, C, B)

形成されるネットワーク(グラフ構造)は、たとえば次のような感じです。

import matplotlib.pyplot as plt

plt.scatter([0 for _ in range(len(A))], A)
plt.scatter([1 for _ in range(len(B))], B)
plt.scatter([2 for _ in range(len(C))], C)
for edge in edges_AB:
    plt.plot([0, 1], [A[edge[0]], B[edge[1]]], color="b", alpha=0.5)
for edge in edges_BC:
    plt.plot([1, 2], [B[edge[0]], C[edge[1]]], color="b", alpha=0.5)
plt.xticks([0, 1, 2], ["A", "B", "C"], color="k", fontsize=12, rotation=0)
plt.show()

MetaPath2Vec試す2_10_0.png

話をややこしくしてしまうかもしれませんが、AC間のエッジの引き方も、説明しておきましょう。ここでは、AとCが1個のBを介して間接的につながっている場合に、AC間にエッジを引くことにします。

edges_AC = []
for x in edges_AB:
    for y in edges_BC:
        if x[1] == y[0]:
            if [x[0], y[1]] not in edges_AC:
                edges_AC.append([x[0], y[1]])

そうすると、AC間のエッジは次のようになります。ただし、話をややこしくしてしまうかもしれませんが、このエッジは学習には用いません。学習結果を確認するときにのみ使う予定です。

import matplotlib.pyplot as plt

plt.scatter([0 for _ in range(len(A))], A)
plt.scatter([2 for _ in range(len(C))], C)
for edge in edges_AC:
    plt.plot([0, 2], [A[edge[0]], C[edge[1]]], color="b", alpha=0.5)
plt.xticks([0, 2], ["A", "C"], color="k", fontsize=12, rotation=0)
plt.show()

MetaPath2Vec試す2_14_0.png

実際に使うデータ

以上、データ構造を説明するため、小さなサイズのデータを用いました。ここから先は、実際に使うデータを作成します。

import numpy as np

num = 100

A = np.random.rand(num * 2)
A.sort()
B = np.random.rand(num * 4)
B.sort()
C = np.random.rand(num)
C.sort()

edges_AB = get_edges(5, A, B)
edges_BC = get_edges(5, B, C)
edges_BA = get_edges(5, B, A)
edges_CB = get_edges(5, C, B)

このようなネットワーク(グラフ構造)になります。

import matplotlib.pyplot as plt

plt.scatter([0 for _ in range(len(A))], A, alpha=0.5)
plt.scatter([1 for _ in range(len(B))], B, alpha=0.5)
plt.scatter([2 for _ in range(len(C))], C, alpha=0.5)
for edge in edges_AB:
    plt.plot([0, 1], [A[edge[0]], B[edge[1]]], color="b", alpha=0.01)
for edge in edges_BC:
    plt.plot([1, 2], [B[edge[0]], C[edge[1]]], color="b", alpha=0.01)
plt.xticks([0, 1, 2], ["A", "B", "C"], color="k", fontsize=12, rotation=0)
plt.show()

MetaPath2Vec試す2_18_0.png

学習に用いない、AC間のエッジも計算しておきましょう。

edges_AC = []
for x in edges_AB:
    for y in edges_BC:
        if x[1] == y[0]:
            if [x[0], y[1]] not in edges_AC:
                edges_AC.append([x[0], y[1]])

こんな感じです。

import matplotlib.pyplot as plt

plt.scatter([0 for _ in range(len(A))], A, alpha=0.5)
plt.scatter([2 for _ in range(len(C))], C, alpha=0.5)
for edge in edges_AC:
    plt.plot([0, 2], [A[edge[0]], C[edge[1]]], color="b", alpha=0.01)
plt.xticks([0, 2], ["A", "C"], color="k", fontsize=12, rotation=0)
plt.show()

MetaPath2Vec試す2_22_0.png

HeteroData

作成したネットワークを、HeteroData クラスで実装しましょう。

data = torch_geometric.data.HeteroData()
data['A'].y = torch.tensor(np.where(A > 0.5, 1, 0))
data['A'].y_index = torch.argsort(torch.tensor(A))
data['A'].num_nodes = len(A)
data['B'].num_nodes = len(B)
data['B'].y_index = torch.argsort(torch.tensor(B)) ##
data['C'].y = torch.tensor(C)
data['C'].y_index = torch.argsort(torch.tensor(C))
data['C'].num_nodes = len(C)
data[('A', 'A2B', 'B')].edge_index = torch.tensor(edges_AB).T
data[('B', 'B2C', 'C')].edge_index = torch.tensor(edges_BC).T
data[('B', 'B2A', 'A')].edge_index = torch.tensor(edges_BA).T
data[('C', 'C2B', 'B')].edge_index = torch.tensor(edges_CB).T
data
HeteroData(
  [1mA[0m={
    y=[200],
    y_index=[200],
    num_nodes=200
  },
  [1mB[0m={
    num_nodes=400,
    y_index=[400]
  },
  [1mC[0m={
    y=[100],
    y_index=[100],
    num_nodes=100
  },
  [1m(A, A2B, B)[0m={ edge_index=[2, 2046] },
  [1m(B, B2C, C)[0m={ edge_index=[2, 2003] },
  [1m(B, B2A, A)[0m={ edge_index=[2, 2046] },
  [1m(C, C2B, B)[0m={ edge_index=[2, 2003] }
)

MetaPath2Vec (embedding_dim = 2)

次のようにして MetaPath2Vec のモデルを実装しましょう。まずは embedding_dim = 2 とします。

from torch_geometric.nn import MetaPath2Vec

embedding_dim = 2

metapath = [
    ('A', 'A2B', 'B'),
    ('B', 'B2C', 'C'),
    ('B', 'B2A', 'A'),
    ('C', 'C2B', 'B'),
    ]

model = MetaPath2Vec(data.edge_index_dict, 
                     embedding_dim=embedding_dim,
                     metapath=metapath,
                     walk_length=4, 
                     context_size=3,
                     walks_per_node=3,
                     num_negative_samples=1,
                     sparse=True
                    ).to(device)

トレーニングとテストのコードです。

def train(epoch, log_steps=500, eval_steps=1000):
    model.train()

    total_loss = 0
    for i, (pos_rw, neg_rw) in enumerate(loader):
        optimizer.zero_grad()
        loss = model.loss(pos_rw.to(device), neg_rw.to(device))
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
        if (i + 1) % log_steps == 0:
            print((f'Epoch: {epoch}, Step: {i + 1:05d}/{len(loader)}, '
                   f'Loss: {total_loss / log_steps:.4f}'))
            total_loss = 0

        if (i + 1) % eval_steps == 0:
            acc = test()
            print((f'Epoch: {epoch}, Step: {i + 1:05d}/{len(loader)}, '
                   f'Acc: {acc:.4f}'))
    
    return total_loss

@torch.no_grad()
def test(train_ratio=0.1):
    model.eval()

    z = model('A', batch=data.y_index_dict['A'].to(device))
    y = data.y_dict['A']

    perm = torch.randperm(z.size(0))
    train_perm = perm[:int(z.size(0) * train_ratio)]
    test_perm = perm[int(z.size(0) * train_ratio):]

    return model.test(z[train_perm], y[train_perm], z[test_perm],
                      y[test_perm], max_iter=150)

モデルのローダーとオプティマイザを初期化します。

loader = model.loader(batch_size=128, shuffle=True, num_workers=2)
optimizer = torch.optim.SparseAdam(list(model.parameters()), lr=0.01)

学習開始。ベストモデルを best_model として保存します。

import copy

loss_hist = []
acc_hist = []
best_score = None
for epoch in range(600):
    loss = train(epoch)
    acc = test()
    loss_hist.append(loss)
    acc_hist.append(acc)
    if best_score is None or best_score < acc:
        best_model = copy.deepcopy(model)
        best_score = acc
        print(f'Epoch: {epoch}, Accuracy: {acc:.4f}, Loss: {loss: .6f}')
Epoch: 0, Accuracy: 0.5278, Loss:  3.505347
Epoch: 4, Accuracy: 0.5333, Loss:  3.430402
Epoch: 6, Accuracy: 0.5556, Loss:  3.333481
Epoch: 50, Accuracy: 0.5667, Loss:  2.891399
Epoch: 77, Accuracy: 0.6056, Loss:  2.772557
Epoch: 81, Accuracy: 0.6500, Loss:  2.747386
Epoch: 95, Accuracy: 0.6778, Loss:  2.678413
Epoch: 99, Accuracy: 0.6833, Loss:  2.662748
Epoch: 120, Accuracy: 0.7278, Loss:  2.539922
Epoch: 140, Accuracy: 0.7444, Loss:  2.418053
Epoch: 145, Accuracy: 0.7556, Loss:  2.350473
Epoch: 148, Accuracy: 0.7722, Loss:  2.356267
Epoch: 151, Accuracy: 0.7889, Loss:  2.363546
Epoch: 155, Accuracy: 0.7944, Loss:  2.343060
Epoch: 163, Accuracy: 0.8000, Loss:  2.264058
Epoch: 167, Accuracy: 0.8056, Loss:  2.252009
Epoch: 173, Accuracy: 0.8278, Loss:  2.263199
Epoch: 180, Accuracy: 0.8444, Loss:  2.254319
Epoch: 185, Accuracy: 0.8500, Loss:  2.201037
Epoch: 195, Accuracy: 0.8722, Loss:  2.203842
Epoch: 207, Accuracy: 0.8778, Loss:  2.179795
Epoch: 220, Accuracy: 0.8944, Loss:  2.183385
Epoch: 273, Accuracy: 0.9056, Loss:  2.128915
Epoch: 315, Accuracy: 0.9111, Loss:  2.125498
Epoch: 335, Accuracy: 0.9278, Loss:  2.124234
Epoch: 375, Accuracy: 0.9500, Loss:  2.057485
Epoch: 398, Accuracy: 0.9556, Loss:  2.043905
Epoch: 400, Accuracy: 0.9611, Loss:  2.095988
Epoch: 410, Accuracy: 0.9667, Loss:  2.038597
Epoch: 419, Accuracy: 0.9833, Loss:  2.061875
Epoch: 433, Accuracy: 0.9889, Loss:  2.090283
Epoch: 457, Accuracy: 0.9944, Loss:  2.028480
Epoch: 484, Accuracy: 1.0000, Loss:  2.061711

学習履歴は次のようになりました。

plt.plot(loss_hist, label="Loss")
plt.legend()
plt.show()
plt.plot(acc_hist, label="ACC")
plt.legend()
plt.show()

MetaPath2Vec試す2_34_0.png

MetaPath2Vec試す2_34_1.png

各々のノードを潜在空間上にマッピングします。

z_A = best_model('A', batch=data.y_index_dict['A'].to(device)).detach().cpu().numpy()
z_B = best_model('B', batch=data.y_index_dict['B'].to(device)).detach().cpu().numpy()
z_C = best_model('C', batch=data.y_index_dict['C'].to(device)).detach().cpu().numpy()

潜在空間の次元が2以上のときは、可視化のため Isomap などで次元削減します。

from sklearn.manifold import Isomap

embedding_model = Isomap()

z_A_2d = embedding_model.fit_transform(z_A)
z_B_2d = embedding_model.transform(z_B)
z_C_2d = embedding_model.transform(z_C)

潜在空間が2次元のときはそのまま使います。

z_A_2d = z_A
z_B_2d = z_B
z_C_2d = z_C

さて、可視化しましょう。Aタイプのノードは赤丸、Bタイプのノードは緑バツ、Cタイプのノードは青四角とします。AB間のエッジは赤線、BC間のエッジは青線にしました。

import matplotlib.pyplot as plt

plt.figure(figsize=(12, 12))
plt.scatter(z_A_2d[:,0],z_A_2d[:,1],color="red",alpha=0.5,label="A")
plt.scatter(z_B_2d[:,0],z_B_2d[:,1],color="green",alpha=0.5,label="B", marker="x")
plt.scatter(z_C_2d[:,0],z_C_2d[:,1],color="blue",alpha=0.5,label="C", marker="s")

for edge in edges_AB:
    plt.plot(
        [z_A_2d[edge[0],0], z_B_2d[edge[1],0]], 
        [z_A_2d[edge[0],1], z_B_2d[edge[1],1]],
        color='red', alpha=0.05)

for edge in edges_BC:
    plt.plot(
        [z_B_2d[edge[0],0], z_C_2d[edge[1],0]], 
        [z_B_2d[edge[0],1], z_C_2d[edge[1],1]],
        color='blue', alpha=0.05)

plt.legend()
plt.title("2D embedding")
plt.show()

MetaPath2Vec試す2_42_0.png

うーん、解釈が難しそうですね。「似たようなノードと繋がっている同じタイプのノードは、近くに配置されている」と解釈できるでしょうか。「互いに接続しているノード同士が、近くに配置されている」わけではなさそうです。

解釈の助けになるかどうかは分かりませんが、AB間、BC間のエッジは消して、AC間のエッジを緑線で表示してみるとこのようになります。

import matplotlib.pyplot as plt

plt.figure(figsize=(12, 12))
plt.scatter(z_A_2d[:,0],z_A_2d[:,1],color="red",alpha=0.5,label="A")
plt.scatter(z_B_2d[:,0],z_B_2d[:,1],color="green",alpha=0.5,label="B", marker="x")
plt.scatter(z_C_2d[:,0],z_C_2d[:,1],color="blue",alpha=0.5,label="C", marker="s")

for edge in edges_AC:
    plt.plot(
        [z_A_2d[edge[0],0], z_C_2d[edge[1],0]], 
        [z_A_2d[edge[0],1], z_C_2d[edge[1],1]],
        color='green', alpha=0.05)

plt.legend()
plt.title("2D embedding")
plt.show()

MetaPath2Vec試す2_44_0.png

MetaPath2Vec (embedding_dim = 4)

過去記事と同様に、embedding_dim を大きくするとどうなるか実験したいと思います。まずは embedding_dim = 4

MetaPath2Vec試す4_18_0.png

MetaPath2Vec試す4_18_1.png

MetaPath2Vec試す4_21_0.png

MetaPath2Vec試す4_22_0.png

むむっ、embedding_dim = 2 のときよりも結果がつぶれて悪化したように見えますね...

MetaPath2Vec (embedding_dim = 8)

MetaPath2Vec試す8_18_0.png

MetaPath2Vec試す8_18_1.png

MetaPath2Vec試す8_21_0.png

MetaPath2Vec試す8_22_0.png

embedding_dim = 4 のときよりは良いかもしれませんが、embedding_dim = 2 のときのほうがもっと良いな...

MetaPath2Vec (embedding_dim = 16)

MetaPath2Vec試す16_18_0.png

MetaPath2Vec試す16_18_1.png

MetaPath2Vec試す16_21_0.png

MetaPath2Vec試す16_22_0.png

なんだろこれ...理解不能

MetaPath2Vec (embedding_dim = 32)

MetaPath2Vec試す32_18_0.png

MetaPath2Vec試す32_18_1.png

MetaPath2Vec試す32_21_0.png

MetaPath2Vec試す32_22_0.png

おっ?なんとなく理解「可」能っぽい構造になってきた?

MetaPath2Vec (embedding_dim = 64)

MetaPath2Vec試す64_18_0.png

MetaPath2Vec試す64_18_1.png

MetaPath2Vec試す64_21_0.png

MetaPath2Vec試す64_22_0.png

タイプが異なっていても、関係の深いノード間が近くに配置されるようになってきた、かも?

MetaPath2Vec (embedding_dim = 128)

MetaPath2Vec試す128_18_0.png

MetaPath2Vec試す128_18_1.png

MetaPath2Vec試す128_21_0.png

MetaPath2Vec試す128_22_0.png

理解不能理解不能理解不能理解不能ッ...!

Node2Vec (embedding_dim = 2)

それでは、次に Node2Vec で計算してみて、 MetaPath2Vec の結果と比較しますよ! 基本的には過去記事と同じなので参照していただくとして、主な変更部分だけお示ししますね。

import numpy as np
from torch_geometric.data import Data, InMemoryDataset

class HeteroDataset(InMemoryDataset):
    def __init__(self, transform = None):
        super().__init__('.', transform)

        embeddings = np.concatenate([A, B, C])
        embeddings = torch.tensor([[x] for x in embeddings])

        edges = []
        for edge in edges_AB:
            edges.append([edge[0], edge[1] + len(A)])
        for edge in edges_BC:
            edges.append([edge[0] + len(A), edge[1] + len(A) + len(B)])
        edges = torch.tensor(edges).T

        ys = np.concatenate([A, B, C])
        ys = np.where(ys > 0.5, 1, 0)
        ys = torch.tensor(ys)

        data = Data(x=embeddings, edge_index=edges, y=ys)
        self.data, self.slices = self.collate([data])
dataset = HeteroDataset()
data = dataset.data
data.x = torch.zeros_like(data.x)
embedding_dim = 2
model = torch_geometric.nn.Node2Vec(
    data.edge_index, embedding_dim=embedding_dim, walk_length=20,
    context_size=10, walks_per_node=20,
    num_negative_samples=10, p=0.5, q=1, sparse=True).to(device)

loader = model.loader(batch_size=64, shuffle=True, num_workers=2)
optimizer = torch.optim.SparseAdam(list(model.parameters()), lr=0.01)

MetaPath2Vec試すNode2Vec比較2_20_0.png

MetaPath2Vec試すNode2Vec比較2_20_1.png

z = best_model(torch.arange(data.num_nodes, device=device)).detach().cpu().numpy()
z.shape
z_A = z[:len(A), :]
z_B = z[len(A):len(A)+len(B), :]
z_C = z[len(A)+len(B):, :]

MetaPath2Vec試すNode2Vec比較2_25_0.png

MetaPath2Vec試すNode2Vec比較2_26_0.png

なんだこれはw

よく分からないが、これはこれで面白いww

Node2Vec (embedding_dim = 4)

MetaPath2Vec試すNode2Vec比較4_20_0.png

MetaPath2Vec試すNode2Vec比較4_20_1.png

MetaPath2Vec試すNode2Vec比較4_24_0.png

MetaPath2Vec試すNode2Vec比較4_25_0.png

うーん、何と言ったら良いやら...w

Node2Vec (embedding_dim = 8)

MetaPath2Vec試すNode2Vec比較8_20_0.png

MetaPath2Vec試すNode2Vec比較8_20_1.png

MetaPath2Vec試すNode2Vec比較8_24_0.png

MetaPath2Vec試すNode2Vec比較8_25_0.png

おわw 面白いw これ、今回自作したデータ構造を非常によく表しているかもしれない!

Node2Vec (embedding_dim = 16)

MetaPath2Vec試すNode2Vec比較16_20_0.png

MetaPath2Vec試すNode2Vec比較16_20_1.png

MetaPath2Vec試すNode2Vec比較16_24_0.png

MetaPath2Vec試すNode2Vec比較16_25_0.png

結果はあまり変わらないけど学習曲線は安定している

Node2Vec (embedding_dim = 32)

MetaPath2Vec試すNode2Vec比較32_20_0.png

MetaPath2Vec試すNode2Vec比較32_20_1.png

MetaPath2Vec試すNode2Vec比較32_24_0.png

MetaPath2Vec試すNode2Vec比較32_25_0.png

うーん、安定しているなぁ。ひょっとして MetaPath2Vec より Node2Vec のほうが良いんじゃない?

Node2Vec (embedding_dim = 64)

MetaPath2Vec試すNode2Vec比較64_20_0.png

MetaPath2Vec試すNode2Vec比較64_20_1.png

MetaPath2Vec試すNode2Vec比較64_24_0.png

MetaPath2Vec試すNode2Vec比較64_25_0.png

お? ぐにゃっと曲がった。でもまだデータ構造をうまく表しているかも。

Node2Vec (embedding_dim = 128)

MetaPath2Vec試すNode2Vec比較128_20_0.png

MetaPath2Vec試すNode2Vec比較128_20_1.png

MetaPath2Vec試すNode2Vec比較128_24_0.png

MetaPath2Vec試すNode2Vec比較128_25_0.png

学習曲線も、潜在空間も、乱れ始めたかな...?

Node2Vec (embedding_dim = 256)

MetaPath2Vec試すNode2Vec比較256_20_0.png

MetaPath2Vec試すNode2Vec比較256_20_1.png

MetaPath2Vec試すNode2Vec比較256_24_0.png

MetaPath2Vec試すNode2Vec比較256_25_0.png

なるほど、もうダメっぽいですね。

まとめ

自作ネットワークを作成して、MetaPath2Vec でノードを潜在空間にプロットしたり、 Node2Vec と比較したりしました。結果としては、Node2Vec のほうが良いかも、となりましたが、これは、今回用いたデータや、ハイパーパラメーターに依存するんだろうとは思います。まだ分からないことも多いので、またいろいろ勉強してみます。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2