ナード戦隊データマン

データサイエンスを用いて悪と戦うぞ

jawiki2w2v: jawikiダンプからword2vecを自動生成

Wikipediaのダンプからword2vecを生成するやり方を知らない初心者がいると思ったので、Ubuntu上で簡単に実行できるスクリプトを作成したので紹介します。

jawiki2w2v

jawiki dumpのURLを渡せばText8とword2vecを生成してくれるツール。 https://github.com/sugiyamath/jawiki2w2v

事前準備

MeCabとneologdを入れる

git clone https://github.com/taku910/mecab && \
    cd mecab/mecab && \
    ./configure --enable-utf8-only && \
    make && \
    make check && \
    make install && \
    pip install --no-cache-dir mecab-python3 && \
    ldconfig && \
    cd ../mecab-ipadic && \
    ./configure --with-charset=utf8 && \
    make && \
    make install
git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd && \
    pushd mecab-ipadic-neologd && \
    yes yes | ./bin/install-mecab-ipadic-neologd -n && \
    popd && \
    yes | rm -r mecab-ipadic-neologd

pythonモジュールを入れる

pip install gensim tqdm beautifulsoup4 mecab-python3

使い方

Usage: ./wiki2w2v.sh <wiki dump url> [neologd path]

例:

git clone https://github.com/sugiyamath/jawiki2w2v
cd jawiki2w2v
./wiki2w2v.sh https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2

注意点

スクリプト全体の実行時間が結構掛かるため、全体のテストが不完全です。もしバグを見つけたらgithubのissueに残していただくか、自分で修正していただけると良いかと思います。

このスクリプトの目的は、自然言語処理の初心者が、コードを見て「wikipediaダンプからword2vecをどのように生成するのか」を学ぶために作成しています。

word2vecで未知語に対処する

Word2vecの問題点は、訓練後のWord2vecのボキャブラリーは固定で、OOV(Out of vocabulary)へ対処できないことです。ここでは、Word2vecのボキャブラリーをsentencepieceでサブワード分割し、それを訓練することで未知語への対処を試みます。

モデル

無題の図形描画 (2).jpg

事前に必要なもの

jawikiから、mecab neologdでトークナイズして訓練したgensimのWord2vecを用意してください。

コード

# coding: utf-8
from gensim.models import KeyedVectors
import sentencepiece as spm
from keras.layers import Input, Dense, Embedding, Flatten, GRU
from keras.layers import SpatialDropout1D
from keras.layers.convolutional import Conv1D, MaxPooling1D
from keras.models import Sequential
from keras.preprocessing.sequence import pad_sequences

def load_data(w2vfile="./jawiki_200.txt", spfile="jawiki.model"):
    model = KeyedVectors.load_word2vec_format(w2vfile, binary=False)
    vocab = model.wv.vocab
    sp = spm.SentencePieceProcessor()
    sp.Load(spfile)
    X = []
    for x in vocab:
        X.append(sp.EncodeAsIds(x))
    y = []
    for x in vocab:
        y.append(model.wv[x])
    X = pad_sequences(X, maxlen=47)
    return X,y,model,vocab,sp

def build_model(vocabsize):
    model = Sequential()
    model.add(Embedding(vocabsize, 200, input_length=47))
    model.add(SpatialDropout1D(0.2))
    model.add(Conv1D(32, kernel_size=3, padding='same', activation='relu'))
    model.add(MaxPooling1D(pool_size=2))
    model.add(GRU(200))
    model.add(Dense(200, activation="linear"))
    model.compile(loss='mse', optimizer='rmsprop', metrics=['mse'])
    return model

if __name__ == "__main__":
    X, y, wv, vocab, sp = load_data()
    model = build_model(sp.get_piece_size()+1)
    model.fit(X, y, epochs=10, batch_size=1000)
    model.save("model.h5")

試す

"11次元超重力理論"という未知語に対してどんな予測をするかを見ます。

# coding: utf-8
from keras.models import load_model
from keras.preprocessing.sequence import pad_sequences
import sentencepiece as spm
from gensim.models import KeyedVectors
import numpy as np

def cossim(x,y):
    return np.dot(x,y)/(np.linalg.norm(x)*np.linalg.norm(y))

model = load_model("./model.h5")
sp = spm.SentencePieceProcessor()
sp.Load("./jawiki_sp_model/jamodel/jawiki.model")
wv = KeyedVectors.load_word2vec_format("jawiki_200.txt", binary=False)

example = "11次元超重力理論"
ex = sp.EncodeAsIds(example)
ex = pad_sequences([ex], maxlen=47)
pred = model.predict(ex)
ks = ["ペット", "政治", "勉強", "学問"]
a = {k:wv[k] for k in ks}
print(wv.most_similar(pred))
print({k:cossim(x,pred[0]) for k,x in a.items()})

出力:

[
  ('地球流体力学', 0.5742046236991882), 
  ('散逸構造', 0.5524085760116577), 
  ('気体分子運動論', 0.5428990125656128), 
  ('カオス理論', 0.5426242351531982), 
  ('ワインバーグ=サラム理論', 0.5341025590896606), 
  ('連続体力学', 0.5340026617050171), 
  ('2状態系', 0.5339871644973755), 
  ('漸近的自由性', 0.5332730412483215), 
  ('ニュートン力学', 0.5317675471305847), 
  ('古典物理学', 0.531417965888977)
]

{'ペット': -0.015529865, '政治': 0.33418512, '勉強': -0.042449825, '学問': 0.3523173}

移動平均を使って感情を気分へ変換する

感情や気分の心理学的な定義は、感情を使ったアプリの開発に役立てられると考えています。ここでは、感情の構成要素と、それを気分へ変換する方法を論文1をもとに書きます。

概要

emotion_and_mood.GIF

感情は、ArousalとValenceという構成要素から成ります。Arousalは感情の覚醒度合いで、高ければ覚醒、低ければ鎮静を表します。感情クラスとArousal,Valenceの対応2を示した図は以下です。

Table 2

Emotion ratings for arousal, valence, CF-self, CF-other, and CF-overall in alphabetical order.

EmotionArousalValenceCF-selfCF-otherCF-overall
Affectionate2.92.576.415.86.105
Aggressive8.127.674.265.95.08
Aggrieved5.915.654.855.054.95
Aghast7.26.473.714.434.07
Agonized6.997.522.423.212.815
Amused3.452.476.46.096.245
Angry8.067.54.585.384.98
Anxious7.046.594.075.24.635
Apologetic4.734.844.434.334.38
Ashamed5.886.013.563.83.68
Bored4.045.833.745.354.545
Carefree3.42.346.626.346.48
Caring3.653.066.535.486.005
Compassionate4.955.474.564.344.45
Concerned6.275.65.585.515.545
Confident2.72.616.555.526.035
Confused6.296.143.554.43.975
Contemptuous6.467.532.633.623.125
Content2.052.286.355.565.955
Cross6.976.685.226.115.665
Curious5.013.346.956.036.49
Desperate7.487.533.544.163.85
Disappointed5.956.734.725.264.99
Disgusted6.877.423.343.893.615
Doubtful6.056.254.855.455.15
Embarrassed4.965.044.194.734.46
Enthusiastic5.732.665.24.764.98
Envious7.546.753.895.094.49
Expectant5.373.575.495.885.685
Frantic8.417.413.273.53.385
Frustrated6.97.464.215.44.805
Grateful32.526.465.295.875
Grievous6.266.823.984.64.29
Guilty6.597.033.43.493.445
Hateful8.168.272.423.452.935
Humble4.965.93.483.253.365
Hurt6.266.643.884.764.32
In love6.271.725.485.525.5
Interested3.962.547.366.196.775
Jaunty5.983.984.354.94.625
Jealous6.146.923.265.24.23
Joyful4.5526.586.26.39
Jubilant5.081.775.174.624.895
Jumpy7.226.373.544.413.975
Lonely5.787.173.855.144.495
Lyrical5.083.185.035.25.115
Melancholic4.425.764.164.234.195
Offended6.046.493.925.274.595
Panic8.267.782.83.363.08
Pardoning3.563.365.174.134.65
Passionate5.692.216.114.785.445
Proud4.122.85.385.445.41
Relaxed1.722.376.075.085.575
Relieved2.682.455.385.025.2
Remorseful5.295.533.333.353.34
Sad5.916.64.515.484.995
Shocked7.566.863.263.893.575
Sick6.717.63.654.233.94
Surprised5.744.094.735.144.935
Triumphant5.53.483.774.093.93
Troubled6.435.774.895.395.14
Wistful5.476.033.523.743.63

The German translations were used in the actual questionnaire.

提案手法では、この感情を気分へ変換するために、フレームを用いています。気分は、ある期間(フレーム)内の感情の系列から、何らかの写像Fを対応させることで数値的に変換し、この数値を量子化することで気分を分類します。

frame_emotions.GIF

写像関数Fは以下のようなものが考えられます:

  • 平均
  • 最大値
  • 最も継続した値
  • 最初に経験した値
  • 最後に経験した値

eval.GIF

この中で最も精度の高いのは、移動平均のようです。

jupyterで実行

感情の乱数を生成します。

from random import random
size = 100
rate = 0.3
es_tmp = [(2*(random()-0.5), 2*(random()-0.5)) for _ in range(size)]
es = []
for e in es_tmp:
    if random() < rate:
        es.append(e)
    else:
        es.append((0,0))

移動平均関数を作成します。

import numpy as np
def mean(es, n=10):
    out1 = []
    out2 = []
    out3 = []
    for i,_ in enumerate(es):
        s = i-n
        if s < 0:
            s = 0
        target = es[s:i+1]
        tmp_v = [x[0] for x in target]
        tmp_a = [x[1] for x in target]
        x = sum(tmp_v)/n
        y = sum(tmp_a)/n
        out1.append(x)
        out2.append(y)
        z = np.dot(x,y)
        if z > 0:
            out3.append(2-np.sign(x))
        elif z < 0:
            out3.append(3+np.sign(x))
        else:
            out3.append(0)
    return out1, out2, out3

V, A, Q = mean(es, 10)

気分の減衰をシミュレートします。

os = [[],[],[],[]]
r = 0.9
for i, (x,y,c) in enumerate(zip(V,A,Q)):
    try:
        for j in range(len(os)):
            os[j].append(os[j][i-1]*r)
    except:
        for j in range(len(os)):
            os[j].append(0)
    os[int(c-1)][i] = abs(x*y)

プロットします。

%matplotlib inline
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
plt.figure(num=None, figsize=(9, 9), dpi=80, facecolor='w', edgecolor='k')
plt.subplot(411)
plt.scatter(x=V, y=A, c=Q)
plt.subplot(412)
for o in os:
    plt.plot(o)
plt.legend(["amusement","anxiety","distress","calmness"], loc=1)
plt.subplot(413)
plt.plot([x[0] for x in es])
plt.legend(["valence"], loc=1)
plt.subplot(414)
plt.plot([x[1] for x in es], "y")
plt.legend(["arousal"], loc=1)

出力

plot_mood.png

共起頻度でタグ予測しMRRで評価

共起頻度とは、2つの対象x,yが現時点までの全イベントの中で同時に現れた回数のことです。今回は、インスタグラムから特定のユーザーのタグを抽出し、共起頻度を用いて予測し、それを平均適合率と平均逆順位を用いてスコアリングします。

問題定義

  1. ユーザーは過去のi時点で入力したタグリストT(i)を持つ。
  2. t ∈ T(i) はタグリスト内の1つのタグ。
  3. ユーザーは現在の時間cにおいて、すでにC(c)を入力済みである。
  4. C(c), T(k),k=1,..,c-1を用いて、T(c)\C(c)を予測するのが目的。

事前準備

pip install instaloader

実行方法

git clone https://github.com/sugiyamath/qiitatag_predictor
cd qiitatag_predictor

execute.pyというファイルの以下の部分のコメントを外す。

    # 以下のコメントを外してください
    #out = get_user_tags(username)
    #with open(filename, "w") as f:
    #    for x in out:
    #        f.write('\t'.join(x)+'\n')

外したあと、以下のように実行:

python execute.py 収集したいinstagramユーザID

出力

出力は3つあります。

  • out.json : 予測系列
  • score_MRR.json : 平均逆順位
  • score_AvPrec.json : 平均適合率

考察

過去の入力タグの系列を使い、現在の入力におけるタグ候補を予測する問題は、以下の困難さがあります:

  • 使えるデータはタグリストと時間しかない。
  • タグが持つ特徴量を抽出するのが難しい。
  • 未知のタグを予測することは難しい。
  • あるユーザーのタグの予測に他のユーザのタグデータを使えない。

通常、この種の問題は、タグ以外の何らかの特徴量(タイトル、ユーザー属性、等)を使うことができますが、この問題では完全にタグの系列のみで予測しなければなりません。

タグをグラフに落とし込み、グラフ特徴量を考えることもできますが、タグの共起だけを見て予測するため、T(i)は集合であり、リストではありません。つまり、[T(0),...,T(c-1)]には時系列は存在しますが、t ∈ T(i)には時系列が存在しない点に注意が必要です。

誰か、この問題のよい解決策を教えて!

ElasticsearchのLTRプラグインの使い方の実例

以前、LTRについての概略を書きましたが、技術的な詳細をあまり書かなかったので、今回はElasticsearchを用いた訓練の仕方を書きます。

利用するプラグイン

以下のプラグインを利用します: https://github.com/o19s/elasticsearch-learning-to-rank

パイプライン

検索エンジンにおけるLTRの運用の仕方は、以下の流れで行うことができます。

  1. クローリング。
  2. クローリングしたドキュメントのインデクシング。
  3. インデクシングしたドキュメントの特徴量を求め、格納する。
  4. 訓練データ(クエリ、ドキュメント)を用意する。
  5. 特徴量を検索システムのクエリに置き換え、訓練する。
  6. テストする。

ここでは、特にインデクシング時とその後にどのようにして特徴量を求めるのか、そしてそれをどうやって訓練するのかについて見ていきます。

インデクシング

インデクシングは以下のようなスクリプトを使うことができます。 https://github.com/sugiyamath/ltr_experiments/blob/master/indexing/index_fast.py

コンテンツ、タイトル、URLなどはトークナイズしてインデクシングする設定を行ってください。 https://qiita.com/mikika/items/1e83c97865c3148b3a7b

この際、高速に求まる特徴量を同時に格納してしまいます。例えば、url内のスラッシュの数のカウントなどが該当します。

高速に求まらない特徴量

高速に求まらない特徴量は、あとからUpdateします。以下のスクリプトが例です: https://github.com/sugiyamath/ltr_experiments/tree/master/scripts

例えば、pagerankなどを求めたcsvをupdateによってelasticsearchへ格納するには、

#!/usr/bin/python3

from elasticsearch import Elasticsearch, helpers
from tqdm import tqdm
import pandas as pd


if __name__ == "__main__":
    
    es = Elasticsearch('192.168.88.85:9200')
    df = pd.read_csv('/home/shun/work/data/nodes_and_pagerank.csv',
                     header=None, names=["id", "pr"])
    df = pd.merge(df, pd.read_csv('/home/shun/work/data/nodes_and_QoL.csv',
                                  header=None, names=["id", "qol"]))
    df = pd.merge(df, pd.read_csv('/home/shun/work/data/nodes_and_QoC.csv',
                                  header=None, names=["id", "qoc"]))
    df = pd.merge(df, pd.read_csv('/home/shun/work/data/nodes_and_degree.txt',
                                  header=None, names=["id", "out", "in"]))
    df = pd.merge(df, pd.read_csv('/home/shun/work/data/nodes_and_urlf.csv'))

    df.to_csv("/home/shun/work/data/test.csv", index=False)
    df = df.sort_values(by="id")

    actions = [{
        '_op_type': 'update',
        '_index': 'webpage2',
        '_type': 'page',
        '_id': d['id'],
        'doc': {
            "pagerank": float(d['pr']),
            "QoC": float(d['qoc']),
            "QoL": float(d['qol']),
            "num_outlink": int(d['out']),
            "num_inlink": int(d['in']),
            "len_url": int(d['len_url']),
            "num_slash_in_url": int(d['num_slash_in_url']),
            "qmark_in_url": bool(d['qmark_in_url'])
        }
    } for i, d in tqdm(df.iterrows())]
    
    chunk_size = 10000

    proceeded = 0
    
    for i in tqdm(range(1+int(len(actions)/chunk_size))):
        try:
            helpers.bulk(
                es, actions[i*chunk_size:(i+1)*chunk_size],
                raise_on_exception=False)
            proceeded += 1
        except Exception as e:
            print(e)
    print("proceeded actions:", proceeded*chunk_size)

のようなスクリプトを実行します。つまり、

  1. 特徴量を求めるステップ。
  2. 特徴量をアップロードするステップ。

に分かれます。特に、PageRankなどのリンクベース特徴量は、それ自体が求めるのに膨大な計算を必要とするので、このような分離をしておくことになります。

訓練データの用意

RankLibを使うので、以下のような訓練データを定義します: https://github.com/sugiyamath/ltr_experiments/blob/master/LTR_o19s/sample_judgments.txt

# grade (0-4) queryid docId   title
# 
# Add your keyword strings below, the feature script will
# Use them to populate your query templates
#
# qid:1: 医療
# qid:2: 統合失調症
# qid:3: 風邪
# qid:4: 骨折
# qid:5: サッカー
# qid:6: 野球
# qid:7: 筋トレ
# qid:8: まとめ
# qid:9: 美味しい食べ物
# qid:10: 理論
# qid:11: 頭痛
# qid:12: 筋肉痛
# qid:13: 風邪薬
# qid:14: スポーツ
# qid:15: 健康
# qid:16: 画像
# qid:17: 政治
# qid:18: 動画
# qid:19: コーラ
# qid:20: 美味しいコーヒーの淹れ方
# qid:21: 速報
# qid:22: 花粉症
# qid:23: wikipedia
#
# https://sourceforge.net/p/lemur/wiki/RankLib%20File%20Format/
#
#
4  qid:1 #   1   qlife
4  qid:1 #   21134   qlife pro news 1
4  qid:1 #   21146   qlife pro news 2
4  qid:1 #   21138   qlife pro
3  qid:1 # 21136 qlife pro paper translate
2  qid:1 # 182492    kaigo job navi
1  qid:1 #   182495  kaigo job navi: shikaku
1  qid:1 #   1973    tisato chuo clinic
0  qid:1 #   170897  qlife kuchikomi
0  qid:1 #   10819   tiken fumin
0  qid:1 #   9410    qlife otoiawase
0  qid:1 #   183085  mens skin care
0  qid:1 #   8188    qlife kuchikomi
0  qid:1 # 78    qlife kuchikomi policy
.
.
.

これは、スコアの値と、クエリ・ドキュメントペアを対応させたものです。この訓練データは次のステップで特徴量へ変換されます。

特徴量の定義

特徴量は、jsonファイルで定義します。このjsonは、その特徴量をElasticsearch上で取得するクエリです。

以下のリンクでは、定義されたjsonリストの例をアップロードしておきました: https://github.com/sugiyamath/ltr_experiments/tree/master/LTR_o19s

特徴量には2つあり、LTRプラグインの機能によって、クエリやドキュメントのテキストから取得するものと、Elasticsearchのドキュメントと一緒に保存した値の2つがあります。

訓練には、上記リンク内においてあるtrain.pyを実行します。訓練による精度も出力されます。

参考

[0] https://github.com/o19s/elasticsearch-learning-to-rank [1] https://misreading.chat/2019/01/21/episode-46-an-introduction-to-neural-information-retrieval/

タグレコメンデーション: グラフを使って次のタグを予測

StackLite 1 とは、stackoverflowの質問につけられたタグの一覧のデータセットです。このデータセットを使って、エレガントな方法でタグ候補を予測します。

仮定

  1. 使えるデータは、質問IDとそのIDに対応したタグのみ。
  2. 入力済みのタグから次のタグを予測するのが目的。

データ整形

import pandas as pd
from collections import defaultdict
import itertools as it
import pickle

if __name__ == "__main__":
    tags = defaultdict(list)
    df = pd.read_csv("./Tags.csv")
    for idx,tag in zip(df.Id,df.Tag):
        tags[idx].append(str(tag))

    with open("tags_fix.pkl","wb") as f:
        pickle.dump(tags, f)

グラフへぶち込む

import networkx as nx
import pickle
import tqdm
from collections import defaultdict

if __name__ == "__main__":
    with open("./tags_fix.pkl", "rb") as f:
        data = pickle.load(f)

    G = nx.DiGraph()
    edges = []
    weight = defaultdict(int)
    for k,tags in tqdm.tqdm(data.items()):
        for t1,t2 in zip(tags[0:-1],tags[1:]):
            weight[(t1,t2)] += 1
            edges.append((t1,t2))
    wedges = [(e[0],e[1],1.0/weight[e]) for e in tqdm.tqdm(edges)]
        
        
    G.add_weighted_edges_from(wedges)

    with open("graph.pkl", "wb") as f:
        pickle.dump(G, f)

予測スクリプトの作成 (sample.py)

import pickle
import networkx as nx
import itertools as it
import sys

if __name__ == "__main__":
    with open("./graph.pkl", "rb") as f:
        G = pickle.load(f)
    
    tags = sys.argv[1].split(",")

    cands = []
    for x in it.combinations(tags, 2):
        cands += nx.dijkstra_path(G,x[0],x[1])
        cands += nx.dijkstra_path(G,x[1],x[0])

    cands = list(set(cands))
    for t in tags:
        cands.remove(t)

    print(cands)

予測の実行

python sample.py tensorflow,machine-learning,deep-learning

出力

['classification', 'neural-network', 'artificial-intelligence', 'scikit-learn', 'backpropagation']

説明

データから有向グラフを作成していますが、頻度が高いエッジほど重みは低くなります。入力タグリストから重みが最小になるパスを見つけ、そのパスの中に含まれるタグを候補にします。

なにがエレガントか

機械学習を使っていないこと。この問題に機械学習を使う場合、Tag.csvだけ使うことを仮定すると特徴量が少なすぎます。しかし、このようなグラフ操作の問題にすれば、特徴量は共起頻度の逆数だけでいいわけです。