LoginSignup
79
41

More than 1 year has passed since last update.

グラフニューラルネットワークでQiitaのタグづけをレコメンドする

Last updated at Posted at 2021-12-07

本記事はNTTドコモR&Dアドベントカレンダー2021の8日目の記事です.

こんにちは、NTTドコモの橋本(@dcm_hashimotom)です.
業務ではレコメンド関連の技術開発・施策検討を行っており,主にPythonやBigQuery, Apache Sparkを触ってます.

SNSなどで投稿したコンテンツの検索性を上げるためには,そのコンテンツへのタグ(またはハッシュタグ)の付与が重要です.Qiitaではタグは5つまで付与することができ,タグを指定した絞り込み検索や,マイページでのプロフィールに使われております.しかし,タグの付与はユーザ手動なものが多く(要出典),検索性が高いものを選択するためには,ドメイン知識が必要です.なので,タグを付ける際に「このタグがついた投稿では他にこんなタグもついてます」的なレコメンドがあれば有用そうです.また,レコメンドということですが,近年レコメンド技術ではGraph Neural Network(GNN)を用いたものがホットですよね!!

というわけで本記事では、GNNを用いてQiitaタグをembeddingしたTech2Vecを作成し,さらに作成したベクトルを用いてタグ付けレコメンドエンジン(仮)を作ってみようと思います.

Word2Vecにおいて、意味が近い単語はそのベクトルも近傍にあるように、Tech2Vecでは似ている"技術"はそのベクトルも近傍にあるようになることを期待してます.

それでは早速やっていきましょう!

実行環境

今回の実行環境は下記のとおりです.
- macOS Big Sur 11.6 (x86)
- Python 3.7.6 / pytorch 1.5.1 / dgl 0.6.1

本記事ではGNNライブラリにDGL(pytorchバックエンド)を使っていきます.

グラフ構築

扱うデータは2021のQiita記事データになります.
Qiitaの投稿記事からデータセット作った
をもとにした得られたデータを使いました.

pandas DataFrameでデータを読み込むと下記のようになります.

import pandas as pd

df = pd.read_csv('2021_qiita.tsv', sep='\t')
df.head()
created_at updated_at id title user likes_count comments_count page_views_count url tags
0 2021-01-01T09:09:17+09:00 2021-01-01T09:09:17+09:00 92cc0a5f54f766239da9 エンベデッドシステムスペシャリストに合格しました {'description': '', 'facebook_id': 'ryo.muta.9... 6 0 NaN https://qiita.com/... [{'name': 'ポエム', 'versions': []}, {'name': '情報...
1 2021-01-01T09:10:42+09:00 2021-01-01T09:47:24+09:00 99e26397c3ae43a0cfc6 JitsiMeet(Web会議サーバー)でAI(IBMWatson)と会話する {'description': '関東在住のSE', 'facebook_id': '', ... 11 0 NaN https://qiita.com/... [{'name': 'asterisk', 'versions': []}, {'name'...
2 2021-01-01T09:12:25+09:00 2021-01-01T09:12:25+09:00 dc948d9558cf7612eba8 WordpressのSearchRegexでリンクタグを全部消す正規表現 {'description': '元アフィリエイター。現在は駆け出しプログラマーとして活動中... 0 0 NaN https://qiita.com/... [{'name': 'WordPress', 'versions': []}, {'name...
3 2021-01-01T09:29:47+09:00 2021-01-02T09:44:06+09:00 6ad6aeed65fa71f92e1b TensorFlowObjectDetectionAPIで物体検出モデルを簡易トレーニング {'description': '機械学習/AR/SceneKit/iOS/Swift/Co... 3 0 NaN https://qiita.com/... [{'name': 'MachineLearning', 'versions': []}, ...
4 2021-01-01T09:30:22+09:00 2021-06-14T22:51:10+09:00 34fd50aea330355b94c2 SharePoint/OneDriveのファイルをデスクトップアプリで開くためのショートカッ... {'description': 'エンジニアになりたい製造業勤務。Power Platfor... 14 0 NaN https://qiita.com/... [{'name': 'PowerShell', 'versions': []}, {'nam...

今回は次の方針でグラフを定義してみます.

  • 技術タグをノードとする
  • 同じ記事にあるタグはリンクを張る

例えば、下画像の記事1には Qiita, Python, データ分析, 生存時間解析,生存時間分析 という5つのタグがついております.また,記事2には Python, DeepLearning, 画像認識,レコメンド,KDD2020の5つのタグがついております.これらを上記方針に沿ってグラフが作成をすると右図のグラフのようになります.
graph_image_r1.png

Webサービスでのグラフ構築を調べると,userノードとitemノードを定義したuser-itemグラフが多いです.(userが利用(作成)したitem(記事のタグ)にエッジをはる.)
今回のようなitemノードのみでつくるグラフはあまり一般的でない気がしますが,本記事の目的ではuserのembeddingは不要なのでこの形で進めていこうと思います.

タスクとしてはGNNのリンク予測問題になります.リンク予測問題では,ある2つのノードが与えられた時に,その2つノードにリンク(エッジ)があればスコアが高く,なければスコアが低くなるように学習を進めます.ここでいうスコアはベクトルの近傍か否かで算出されます.「同じ記事におけるタグであるならば似たような技術である」という仮定のもと,類似技術は近傍のベクトルになることを期待します.

まずは上記のQiitaデータからタグの部分を抜き出した2次元のリストを作成します.なお,事前に登場回数が少ないタグ(今回は10以下)は処理対象から外しております.

>>> tag_list[:10]
[['ポエム', '情報処理技術者試験', 'エンベデッドシステムスペシャリスト', '合格体験記'],
 ['asterisk', 'AI', 'Watson', 'ibmcloud', 'Jitsi'],
 ['WordPress', 'Plugin', 'SearchRegex'],
 ['MachineLearning',
  'TensorFlow',
  'ObjectDetectionAPI',
  'colaboratory',
  'ObjectDetection'],
 ['PowerShell', 'SharePoint', 'OneDrive', 'Teams', 'Microsoft365'],
 ['Python', 'OpenCV', '顔認識', 'Keras', '表情認識'],
 ['SAP'],
 ['Python', 'R', 'math', '虚数', '数理考古学'],
 ['SublimeText', 'エラー対処', '初心者エンジニア'],
 ['lisp', 'ISLisp']]

これらリストの要素それぞれがノードになります.

まず,グラフを作るためsrcとdstにわけます.
ここではPythonの組み合わせ計算を使いました.

import itertools
edges = []
for tags in tag_list:
    if len(tags) == 1:
        continue
    combs = itertools.combinations(tags, 2)
    for c in combs:
        edges.append(c)

df_edges = pd.DataFrame({"src": [e[0] for e in edges], "dst": [e[1] for e in edges]}) #便宜上DataFrameに変換
df_edges = df_edges.drop_duplicates().reset_index(drop=True) #重複を削除

df_edgesは下記のようになってます.

src dst
0 ポエム 情報処理技術者試験
1 ポエム エンベデッドシステムスペシャリスト
2 ポエム 合格体験記
3 情報処理技術者試験 エンベデッドシステムスペシャリスト
4 情報処理技術者試験 合格体験記
.. ... ...

次にdgl.graphに対応させるため,ノードにIDを振ります.

# ノードIDを振る
src_list = df_edges["src"].unique().tolist()
dst_list = df_edges["dst"].unique().tolist()
tmp_list = list(set(src_list + dst_list))
df_nodeid = pd.DataFrame()
df_nodeid["nodes"] = tmp_list
df_nodeid["node_id"] = list(range(len(tmp_list)))

# src, dstの形式に整形
df_graph = df_edges.merge(df_nodeid[["nodes", "node_id"]], left_on="src", right_on="nodes", how="left")
df_graph = df_graph.merge(df_nodeid[["nodes", "node_id"]], left_on="dst", right_on="nodes", how="left")
df_graph = df_graph.drop(["nodes_x", "nodes_y"], axis=1)
df_graph = df_graph.rename(columns={"node_id_x": "src_id", "node_id_y": "dst_id"})

df_graphは次のようになります

src dst src_id dst_id
0 ポエム 情報処理技術者試験 4555 355
1 初心者 情報処理技術者試験 7379 355
2 初心者向け 情報処理技術者試験 593 355
3 tips 情報処理技術者試験 8017 355
4 勉強法 情報処理技術者試験 4317 355

さて,dgl.graphへの入力となるデータが作成できました.
ここからはDGLでグラフオブジェクトを作成していきます.

import dgl
import torch
g = dgl.graph((torch.tensor(df_graph["src_id"]), torch.tensor(df_graph["dst_id"])))
g = dgl.to_bidirected(g) #無向グラフに変換するため両方にエッジをもたせる

ここで注意点があります.
src, dstと表現してましたが,今回作りたいグラフは有向グラフではありません.今のままでは,先程の組み合わせ計算(itertool.combinations())でたまたま先頭に来たものがsrcに,後に来たものがdstになった有向グラフになってしまっています.そこで,dgl.to_bidirceted()の処理で両方のエッジを持たせた無向グラフに変換します.DGLでは無向グラフはsrc→dst, dst→src両方にエッジをもたせて表現します.

上記の処理でノード数は8866,エッジ数は210,984のグラフなりました.

>>> g.num_nodes()
8866
>>> g.num_edges() # src->dst, dst->src両方含む
210984

グラフの可視化

作成したグラフを可視化してみましょう.

全体のグラフを見てもよくわからないので,グラフの一部分をサンプルした可視化を試みます.

下記の関数では,指定したノードとその1-hop分隣接のノードをサンプルしたグラフを構築する処理を書いております.サンプルしたい隣接ノードの個数をfanoutパラメータで指定します.

def create_sample_graph(g, sample_nodes, fanout):
    # グラフから指定したノードをサンプル(元グラフノードはそのまま残る)
    sample_graph = dgl.sampling.sample_neighbors(g, sample_nodes, fanout=fanout)
    # エッジを持つノードのみ取り出す.
    src, dst = sample_graph.edges()
    # ノードIDを振り直す
    uniq_nodes, edges = np.unique((src.numpy(), dst.numpy()), return_inverse=True)
    # srcとdstに分ける
    relabeled_src, relabeled_dst = np.reshape(edges, (2, -1))
    # グラフを再構築
    sample_g = dgl.graph((relabeled_src,relabeled_dst))

    return sample_g, uniq_nodes

実装は少しトリッキーになってます.
dgl.sampling.sample_neighbors()が指定したノードからサンプルグラフを作ってくれるのですが,この時点ではsample_graphとgのノード数は同じです.ノード数はサンプルされた部分はエッジをもち,それ以外はエッジをもたないグラフになっております.下記に例を示します.

>>> sample_graph = dgl.sampling.sample_neighbors(g, [8154], fanout=20) #1つのノードとその隣接ノード20個を指定
>>> sample_graph.num_nodes() #ノード数はそのまま
8866
>>> sample_graph.num_edges() #エッジはサンプルされている
20

なので,必要なノードで構成されるグラフを得るためにはエッジがあるノードのみ取り出し,それらにノードIDを振り直す必要があります.
ノードIDを振り直すのにnp.unique()を使っています.np.unique()ではsrc,dstのノードIDをユニークにします.さらに,return_inverseを指定することで,それらが登場したindexを返却します.indexは0から振られるのでちょうど新規のノードIDとして利用できます.ここで,入力はsrc,dstのタプルで渡しておりますが,それらがconcatされた形で処理されることに注意してください.

サンプリングする関数ができましたので,グラフを可視化してみましょう.

今回は「DeepLearning」, 「深層学習」,「ポエム」の3ノードを指定し,それらの1-hop先のノード20個までをサンプルしたグラフを描画してみます.最初に上記3つノードIDを調べます.

>>> df_nodeid[df_nodeid["nodes"].isin(["深層学習", "DeepLearning", "ポエム"])]
nodes node_id
2748 DeepLearning 2748
4555 ポエム 4555
8154 深層学習 8154

それでは描画してみましょう

# グラフのサンプリング
sample_g, uniq_nodes = create_sample_graph(g, [8154, 2748, 4555], 20)
label_dict= {i: label for i, (_id, label) in enumerate(zip(df_nodeid.iloc[uniq_nodes]["node_id"], df_nodeid.iloc[uniq_nodes]["nodes"]))}

# 描画処理
options = {
    'node_color': [[.7, .7, .7]],
    'width': 1,
    'with_labels': True,
    'labels': label_dict,
    'font_family':'IPAexGothic'
}
G = dgl.to_networkx(sample_g).to_undirected()
pos = nx.kamada_kawai_layout(G)
plt.figure(figsize=[15,7])
nx.draw(G, pos, **options)

sample_graph.png

今回はあくまでサンプルであり,指定したノードの全ての隣接ノードやすべてのエッジでないことに注意してください.

指定した3つのノードに関して,同記事につけられたであろうタグのノードが隣接ノードとしてエッジがはられております.想定どおりのグラフができていそうです.

GNN(GraphSAGE)によるTech2Vecの作成

後の処理ではGNNを定義し学習をさせていきますが,DGLのチュートリアルに沿った形式で実装していきます.

コード(クリックして開く)
import torch
import dgl.nn as dglnn
import torch.nn as nn
import torch.nn.functional as F
import dgl.function as fn

class SAGE(nn.Module):
    def __init__(self, in_feats, hid_feats, out_feats):
        super().__init__()
        self.conv1 = dglnn.SAGEConv(
            in_feats=in_feats, out_feats=hid_feats, aggregator_type='mean')
        self.conv2 = dglnn.SAGEConv(
            in_feats=hid_feats, out_feats=out_feats, aggregator_type='mean')

    def forward(self, graph, inputs):
        h = self.conv1(graph, inputs)
        h = F.relu(h)
        h = self.conv2(graph, h)
        return h

class DotProductPredictor(nn.Module):
    def forward(self, graph, h):
        with graph.local_scope():
            graph.ndata['h'] = h
            graph.apply_edges(fn.u_dot_v('h', 'h', 'score'))
            return graph.edata['score']

class Model(nn.Module):
    def __init__(self, in_features, hidden_features, out_features):
        super().__init__()
        self.sage = SAGE(in_features, hidden_features, out_features)
        self.pred = DotProductPredictor()
    def forward(self, g, neg_g, x):
        h = self.sage(g, x)
        return self.pred(g, h), self.pred(neg_g, h)

def construct_negative_graph(graph, k):
    src, dst = graph.edges()
    neg_src = src.repeat_interleave(k)
    neg_dst = torch.randint(0, graph.num_nodes(), (len(src) * k,))
    return dgl.graph((neg_src, neg_dst), num_nodes=graph.num_nodes())

def compute_loss(pos_score, neg_score):
    # Margin loss
    n_edges = pos_score.shape[0]
    return (1 - pos_score + neg_score.view(n_edges, -1)).clamp(min=0).mean()

# 学習部分
node_features = torch.nn.functional.one_hot(torch.tensor(df_nodeid["node_id"]), num_classes=len(df_nodeid["node_id"])).float()
n_features = node_features.shape[1]
k = 5
model = Model(n_features, 100, 50)
opt = torch.optim.Adam(model.parameters())

for epoch in range(1000):
    negative_graph = construct_negative_graph(g, k)
    pos_score, neg_score = model(g, negative_graph, node_features)
    loss = compute_loss(pos_score, neg_score)
    opt.zero_grad()
    loss.backward()
    opt.step()

# Embedding
y = model.sage(g, node_features)
emb = y.detach().numpy()
df_emb = pd.DataFrame(emb, columns=[f"col_{i}" for i in range(emb.shape[1])])
df_emb_nodeid = pd.concat([df_nodeid.reset_index(drop=True), df_emb.reset_index(drop=True)], axis=1)

SAGE クラス

レイヤーは2層ありSAGEConvクラスを用いて実装されております.
aggregator_type='mean'ですので,近傍ノードの集約の際は自身のノードと近傍のノードの平均ベクトルを算出し重みを掛け合わせたものを次のレイヤにforwardさせてます.

DotProductPredictor クラス

リンク予測タスクにおいては2つのノードのスコア(距離など)を算出し,リンクの有無を数値で表します.DotProductPredictorクラスは与えたグラフ構造とノードベクトル(hはここではembeddingされたもの)をもとに,エッジのある2つのノードのスコアを算出します.ここでは単純にベクトル同士の内積を計算しております.

Model クラス

上記2つのクラスをModelクラスで呼べるように定義します.
forwardでは,与えられた正グラフと負例グラフそれぞれのスコアを算出してます.

construct_negative_graph 関数

この関数では与えられたグラフをもとに,本来はエッジ(リンク)がない負例グラフを構築しております.srcノード, dstノードとあったとき,srcはそのままで,dstをグラフ内のノードから乱数で設定することで,本来はエッジ(リンク)がない負例グラフを定義しております.乱数で設定しているが故に正のエッジ(もともとのグラフでも存在する)が含まれる場合もありますが,ここでは簡単のためそれらを取り除く処理を実施しておりません.

compute_loss 関数

lossの関数の定義については,あまり深く追えてませんが,正例グラフのスコアが負例のスコアより大きい場合はlossが小さく,また逆の場合はlossが大きくなるように定義されております.

学習部分

各ノードの初期ベクトルはノード数8866次元のone-hotベクトルにしました.NNの隠れニューロン数は100, 出力ベクトル次元数は50,負例のサンプルノード数は5に設定しました.また,epoch数は決めで1000を設定しました.epochの中で都度負例グラフを作成しbackpropの処理をしております.

Embedding部分

df_emb_nodeidの中身をみてみましょう.

nodes node_id col_0 col_1 ... col_48 col_49
0 バイナリ 0 -0.177811 -1.879942 ... -1.136763 1.127163
1 container 1 0.027849 -1.223250 ... -0.766427 1.096907
2 headタグ 2 -0.174102 -1.204872 ... -0.576451 1.021932
3 組み込みOS 3 -0.885491 -0.635641 ... 0.294162 0.730181
4 maxima 4 -0.309023 -1.341024 ... -1.186237 2.255011

ちゃんとembeddingできてそうです.

近傍探索モデルによるレコメンド

では,期待通りに似ている技術タグが近傍のベクトルになってレコメンドができるか確認していきましょう.今回は近傍探索にAnnoyをつかっていこうと思います.Oh Yeah!

cols = [col for col in df_tmp.columns if "col" in col]
t = AnnoyIndex(50, 'angular')
for i in range(len(df_emb_nodeid)):
    v = df_emb_nodeid[cols].iloc[i, :]
    t.add_item(i, v)
t.build(10)

AnnoyIndexで対象ノードまたはベクトルを入力をすることでその近傍のノードを探索し出力してくれます.

結果

「機械学習」

「機械学習」の近傍の技術タグTop10をみてみましょう.レコメンドでいうと「機械学習」とタグ付けした際に推薦してもらえる候補Top10になります.

i = df_emb_nodeid[df_emb_nodeid["nodes"] == "機械学習"].node_id.values[0]
nns = t.get_nns_by_item(i, 10, search_k=-1, include_distances=False)
# 抽出されたindexを表示
df_emb_nodeid[["nodes", "node_id"]].iloc[nns, :]
nodes node_id
7859 機械学習 7859
8154 深層学習 8154
2748 DeepLearning 2748
5120 MachineLearning 5120
1873 混同行列 1873
7066 DALLE 7066
995 MLP 995
6868 異常検知 6868
7347 前処理 7347
1416 セグメンテーション 1416

お,なんかうまくいってそうです! 👍

他のいくつかのタグもみてみましょう.

「Python」

nodes node_id
8599 Python 8599
4681 LCAO 4681
7488 nnmnkwii 7488
8460 OnlineMathContest 8460
1279 music21 1279
7501 実行環境 7501
8509 Newton法 8509
3568 numerai 3568
4109 共役勾配法 4109
4448 peewee 4448

Pythonのライブラリや,数値計算の用語が並んでます.
同記事のタグはリンクを貼っているので,Python, Ruby, Perl..ではなく,
関連ライブラリが並ぶのかと思います.
本記事の実装のレコメンドとしては正しそうです🤔

「JavaScript」

nodes node_id
4194 JavaScript 4194
2056 c3.js 2056
2190 ECMAScript6 2190
7984 実況中継 7984
1942 卒業研究 1942
747 dotinstall 747
6423 aspida 6423
8549 monigate 8549
2976 VimeoAPI 2976
4141 prismjs 4141

ちょっと謎なタグもありますが,雰囲気はあります.

「Emacs」

nodes node_id
8486 Emacs 8486
8473 ido 8473
210 Vim 210
8310 neovim 8310
2041 Markdown 2041
4303 Terminal 4303
1395 org-mode 1395
7757 マージ 7757
5247 Spacemacs 5247
3184 commandline 3184

EmacsプラグインやVimなどそれっぽいのがならんでますね.
個人的にorg-modeはお世話になってます.

「ポエム」

nodes node_id
4555 ポエム 4555
2440 非エンジニア 2440
7275 初投稿 7275
1616 初心者エンジニア 1616
6581 独学プログラマー 6581
6205 新人教育 6205
6616 オフショア開発 6616
7402 テレワーク 7402
6932 DMMWEBCAMP 6932
2870 未経験エンジニア 2870

入門感があります.

まとめ

本記事ではQiitaタグデータのグラフを構築し,GNNを用いて技術タグの分散表現Tech2Vecを作成しました.さらに作成したベクトルを用いて近傍探索モデルを構築し,類似タグの算出(レコメンド)できるかを確認しました.定量的な評価ではありませんが,まぁまぁ良いemmbeddingができているように感じます.

また,以下の工夫をすることで,精度があげらるかと思います.

  • 前処理の名寄せ処理を行う
  • 特徴ベクトルをone-hotデータでなく工夫したものを入れる
  • モデルのレイヤーやパラメータを調整する
  • src, dstに分けた時にユニークにしないで,出現回数などで重み付けをする
  • レコメンド時にタグの出現回数を考慮する

最後に

本記事のタグもレコメンドしてもらおうと「GNN」を入力してみましたが,登場回数を消す前処理で削られてしまってノードとして入っておりませんでした...もっとGNNの記事が必要ですね..今回利用したデータでは「GNN」の登場回数は7回でした. 

Qiitaに限らず投稿コンテンツにタグをつけるという行動はさまざまなSNSで行われるので,本取り組みの汎用性があるように思っております.それでは,良いお年を〜 👍👍👍

79
41
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
79
41