文章ベクトルSCDVの実装例のご紹介

この記事は約21分で読めます。

今日では、自然言語処理(NLP)の分野で、文章を数値表現に変換するためのさまざまな手法が存在します。
これらの手法は、テキストデータを機械学習アルゴリズムに適用する際に重要な役割を果たしており、機械翻訳、感情分析、トピック分析、文書分類などのタスクに広く利用されています。

今回は文章ベクトルを計算する手法に焦点を当てて、その中でも比較的最近登場した新しい方法Soft Cosine Document Vector: SCDVという手法について紹介し、他の文章ベクトル手法との比較を行いたいと思います。

Sparse Composite Document Vectors: SCDVについて

SCDVは、2017年にMicrosoft Reaserchチームより提案された、文書内の単語の頻度情報を利用して文章をベクトル化する手法です。
論文は以下です。

文章ベクトルを取得する手法は他にもDoc2Vecなど色々ありますが、論文内において文章ベクトルを用いたマルチラベル分類で比較してみた場合、他の文章ベクトル手法で学習させた時よりも高い分類精度が出せたようです。
つまりそれだけ、文章の特徴の偏りをうまく見つけ出し、異なるものとしてうまくベクトル表現できている手法ということになります。

以下がSCDVのアルゴリズムの全容になりますが、計算方法自体はそこまで難しくないようです。

文章データから得られる全単語について、Word2Vecベクトルとidf値を計算しておきます。
この単語ベクトルについて、混合ガウスモデルでKクラス分類に学習し、一つ一つの単語ベクトルが各クラスに属する予測確率を単語ベクトルにかけて連結して、単語ベクトル数xクラスタ数に次元を広げるようなことをします。
これにidf値をかけたものが、単語ベクトル Word-topics vector になります。
これを、文章の構成単語について平均をとって、スパースさせたものを文章ベクトルとして扱う、といった流れになるみたいです。

SCDVおよび各種文章ベクトル手法の実装

それでは、文章ベクトルSCDVとその他の古くからある手法について実装してみて、各文章ベクトルがどのような様子か可視化して見てみようと思います。
具体的には、BoW・tf-idf・Averaged-Word2Vec・Doc2Vec・SCDVをそれぞれ実装してみて、これらをt-SNEで2次元に圧縮して2次元プロットして確認してみます。

文章ベクトル手法の実装にあたって、文章データを用意する必要があります。
今回は前述の論文と同じくニュースコーパスのデータを使いました。
ただし、20クラスも分類していると時間がかかるので、5クラス分だけ適当に取得することにします。

import re
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pylab as plt
import seaborn as sns
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn import datasets, manifold, mixture, model_selection
from gensim.models import Word2Vec
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from xgboost import XGBClassifier

# 適当にトピックカテゴリを選択
categories = [
    'alt.atheism',
    'comp.graphics',
    'rec.sport.baseball',
    'sci.space',
    'talk.politics.guns'
]
train = datasets.fetch_20newsgroups(subset='train', categories=categories)
train.data = np.array(train.data, dtype=np.object)

# それぞれ含まれる文章数をカウント
for i, c in enumerate(categories):
    indices = np.where(train.target == i)
    print(c + ':\t{}'.format(len(train.data[indices])))
alt.atheism:    480
comp.graphics:  584
rec.sport.baseball: 597
sci.space:  593
talk.politics.guns: 546

合計2800件の文章データを取得してきました。

また、各種手法で用いる定数パラメータおよびアナライザーを準備します。
アナライザーは、いずれの手法もまずは文章を単語に分解する必要があるため、入力された文章を単語へ分解する関数です。
ストップワードはもっと他にもたくさん入れるべきでしょうが、ひとまずはこのくらいで。

# BoW, tf-idf, average Word2Vec, Doc2Vec, SCDV
features_num = 200
min_word_count = 10
context = 5
downsampling = 1e-3
epoch_num = 10

# Analyzer
def analyzer(text):
    stop_words = ['i', 'a', 'an', 'the', 'to', 'and', 'or', 'if', 'is', 'are', 'am', 'it', 'this', 'that', 'of', 'from', 'in', 'on']
    text = text.lower() # 小文字化
    text = text.replace('\n', '') # 改行削除
    text = text.replace('\t', '') # タブ削除
    text = re.sub(re.compile(r'[!-\/:-@[-`{-~]'), ' ', text) # 記号をスペースに置き換え
    text = text.split(' ') # スペースで区切る

    words = []
    for word in text:
        if (re.compile(r'^.*[0-9]+.*$').fullmatch(word) is not None): # 数字が含まれるものは除外
            continue
        if word in stop_words: # ストップワードに含まれるものは除外
            continue
        if len(word) < 2: #  1文字、0文字(空文字)は除外
            continue
        words.append(word)

    return words

BoW

まずは一番基礎的なBoWで文章の特徴量を表した場合について。
この辺りはsklearn.feature_extraction.textに関数がすでに用意されているので、積極的に使っていきます。

# BoW
corpus = train.data
count_vectorizer = CountVectorizer(analyzer=analyzer, min_df=min_word_count, binary=True)
bows = count_vectorizer.fit_transform(corpus)
bows.shape # (2800, 5445)

# t-SNEで圧縮して可視化
tsne_bow = manifold.TSNE(n_components=2).fit_transform(bows.toarray())
tsne_bow.shape # (2800, 2)

df_tsne_bow = pd.DataFrame({
    'x': tsne_bow[:, 0],
    'y': tsne_bow[:, 1],
    'category': train.target,
})
df_tsne_bow.plot.scatter(x='x', y='y', c='category', colormap='viridis', figsize=(7, 5), s=20)
plt.show()

これは2次元に圧縮した後の可視化にはなりますが、この図では、あまり分かれてくれていないように見えます。

tf-idf

次にtf-idfです。
これもsklearn.feature_extraction.textに関数が用意されていますので、すぐ作成できます。

# tf-idf
corpus = train.data
tfidf_vectorizer = TfidfVectorizer(analyzer=analyzer, min_df=min_word_count)
tfidfs = tfidf_vectorizer.fit_transform(corpus)
tfidfs.shape # (2800, 5445)

# t-SNEで圧縮して可視化
tsne_tfidf = manifold.TSNE(n_components=2).fit_transform(tfidfs.toarray())
tsne_tfidf.shape # (2800, 2)

df_tsne_tfidf = pd.DataFrame({
    'x': tsne_tfidf[:, 0],
    'y': tsne_tfidf[:, 1],
    'category': train.target,
})
df_tsne_tfidf.plot.scatter(x='x', y='y', c='category', colormap='viridis', figsize=(7, 5), s=20)
plt.show()

こちらは先ほどのBoWと比べると、綺麗に分かれてくれているように見えます。

Averaged-Word2Vec

Word2Vecは単語ベクトルの手法になりますが、文章に含まれる単語をこのベクトルで計算して、平均をとるなどをして一つのベクトルに集約することで文章ベクトルとする方法もよく利用されます。Word2Vecはgensimで簡単に学習でき、ベクトルの変換が可能です。
今回はWord2Vecモデルで表される単語ベクトルの平均(Average)をとって文章ベクトルとしました。

# Averaged-Word2Vec
corpus = [analyzer(text) for text in train.data]
word2vecs = Word2Vec(
    sentences=corpus, iter=epoch_num, size=features_num,
    min_count=min_word_count, window=context, sample=downsampling,
)
avg_word2vec = np.array([word2vecs.wv[list(analyzer(text) & word2vecs.wv.vocab.keys())].mean(axis=0) for text in train.data])
avg_word2vec.shape # (2800, 200)

# t-SNEで圧縮して可視化
tsne_avg_word2vec = manifold.TSNE(n_components=2).fit_transform(avg_word2vec)
tsne_avg_word2vec.shape # (2800, 2)

df_tsne_avg_word2vec = pd.DataFrame({
    'x': tsne_avg_word2vec[:, 0],
    'y': tsne_avg_word2vec[:, 1],
    'category': train.target,
})
df_tsne_avg_word2vec.plot.scatter(x='x', y='y', c='category', colormap='viridis', figsize=(7, 5), s=20)
plt.show()

これも割と文章のラベルごとに分かれてくれているようです。
同じクラスだけども、さらに別の集団として捉えているようなものも見られます。

ちなみに、書籍で『ゼロから作るDeep Learning』の第2弾が最近登場しており、内容は自然言語処理メインになっていて、Word2Vecの解説なども分かりやすく書いているのでおすすめです。

Doc2Vec

Doc2Vecも試してみます。
こちらもgensimから利用可能です。

# Doc2Vec
corpus = [TaggedDocument(words=analyzer(text), tags=[i]) for i, text in enumerate(train.data)]
doc2vecs = Doc2Vec(
    documents=corpus, dm=1,  epochs=epoch_num, vector_size=features_num,
    min_count=min_word_count, window=context, sample=downsampling
) # dm == 1 -> dmpv, dm != 1 -> DBoW
doc2vecs = np.array([doc2vecs.infer_vector(analyzer(text)) for text in train.data])
doc2vecs.shape # (2800, 200)

# t-SNEで圧縮して可視化
tsne_doc2vec = manifold.TSNE(n_components=2).fit_transform(doc2vecs)
tsne_doc2vec.shape # (2800, 2)

df_tsne_doc2vec = pd.DataFrame({
    'x': tsne_doc2vec[:, 0],
    'y': tsne_doc2vec[:, 1],
    'category': train.target,
})
df_tsne_doc2vec.plot.scatter(x='x', y='y', c='category', colormap='viridis', figsize=(7, 5), s=20)
plt.show()

他の分散表現もそうですが、うまく分かれてくれていなさそうな文章が中心辺りに一定数集まっているようにも見えます。

SCDV

ここまで、様々な文章ベクトルについて見てきました。
SCDVについても同様にやってみます。
実装は論文で以下が公開されていますので、こちらも参考にしながら書いてみます。

まずは、全ての単語ベクトルを混合ガウスモデルで学習してクラスタリングします。
論文においては、このクラスタ数を変化させた時に、どのように分類モデルの精度が変化するかを調査しています。
論文を確認するとクラスタ数が60以上からはあまり変化がないように見えますので、クラスタ数は60としました。
他、sparsityは4%、ベクトル次元数は200にしています。

word_vectors = word2vecs.wv.vectors
clusters_num = 60
gmm = mixture.GaussianMixture(n_components=clusters_num, covariance_type='tied', max_iter=50)
gmm.fit(word_vectors)

次に、Word-topics vectorを作成し、単語ごとに単語ベクトルと各クラスの予測確率、idf値を掛け合わせます。

idf_dic = dict(zip(tfidf_vectorizer.get_feature_names(), tfidf_vectorizer._tfidf.idf_))
assign_dic = dict(zip(word2vecs.wv.index2word, gmm.predict(word_vectors)))
soft_assign_dic = dict(zip(word2vecs.wv.index2word, gmm.predict_proba(word_vectors)))

word_topic_vecs = {}
for word in assign_dic:
    word_topic_vecs[word] = np.zeros(features_num*clusters_num, dtype=np.float32)
    for i in range(0, clusters_num):
        try:
            word_topic_vecs[word][i*features_num:(i+1)*features_num] = word2vecs.wv[word]*soft_assign_dic[word][i]*idf_dic[word]
        except:
            continue

出来上がったWord-topics vectorを用いて、文章ごとにベクトルを作成します。

scdvs = np.zeros((len(train.data), clusters_num*features_num), dtype=np.float32)

a_min = 0
a_max = 0

for i, text in enumerate(train.data):
    tmp = np.zeros(clusters_num*features_num, dtype=np.float32)
    words = analyzer(text)
    for word in words:
        if word in word_topic_vecs:
            tmp += word_topic_vecs[word]
    norm = np.sqrt(np.sum(tmp**2))
    if norm > 0:
        tmp /= norm
    a_min += min(tmp)
    a_max += max(tmp)
    scdvs[i] = tmp

p = 0.04
a_min = a_min*1.0 / len(train.data)
a_max = a_max*1.0 / len(train.data)
thres = (abs(a_min)+abs(a_max)) / 2
thres *= p

scdvs[abs(scdvs) < thres] = 0
scdvs.shape # (2800, 12000)

これを同様にt-SNEで圧縮して可視化すると以下のような感じになります。

tsne_scdv = manifold.TSNE(n_components=2).fit_transform(scdvs)
tsne_scdv.shape # (2800, 2)

df_tsne_scdv = pd.DataFrame({
    'x': tsne_scdv[:, 0],
    'y': tsne_scdv[:, 1],
    'category': train.target,
})
df_tsne_scdv.plot.scatter(x='x', y='y', c='category', colormap='viridis', figsize=(7, 5), s=20)
plt.show()

同クラス内でもさらにちらほらと塊の島みたいなものが出来上がっており、より細かく特徴的な文章の分類を表現できるようになっているような気がします。
やっぱり微妙にうまく分かれてくれない文章はちらほらいるようで、元々難しい文章については同様に難しいのでしょう。

分類モデルによる比較

さて、前章にて様々な文章ベクトルを生成することができました。
これを分類モデルに入れて学習させてみて、精度を確認してみます。
論文でもSVMで同様に調べられていますが、今回はXGBoostを使ってみました。

model = XGBClassifier()

df_compare = pd.DataFrame(columns=['name', 'train_accuracy', 'valid_accuracy', 'time'])
scoring = ['accuracy']
cv_trial_num = 8

# BoW
cv_rlts = model_selection.cross_validate(model, bows.toarray(), train.target, scoring=scoring, cv=cv_trial_num, return_train_score=True)
for i in range(cv_trial_num):
    s = pd.Series(['BoW', cv_rlts['train_accuracy'][i], cv_rlts['test_accuracy'][i], cv_rlts['fit_time'][i]], index=df_compare.columns, name='BoW'+str(i))
    df_compare = df_compare.append(s)

# tfidf
cv_rlts = model_selection.cross_validate(model, tfidfs.toarray(), train.target, scoring=scoring, cv=cv_trial_num, return_train_score=True)
for i in range(cv_trial_num):
    s = pd.Series(['tfidf', cv_rlts['train_accuracy'][i], cv_rlts['test_accuracy'][i], cv_rlts['fit_time'][i]], index=df_compare.columns, name='tfidf'+str(i))
    df_compare = df_compare.append(s)

# Word2Vec average
cv_rlts = model_selection.cross_validate(model, avg_word2vec, train.target, scoring=scoring, cv=cv_trial_num, return_train_score=True)
for i in range(cv_trial_num):
    s = pd.Series(['avg_Word2Vec', cv_rlts['train_accuracy'][i], cv_rlts['test_accuracy'][i], cv_rlts['fit_time'][i]], index=df_compare.columns, name='avg_Word2Vec'+str(i))
    df_compare = df_compare.append(s)

# Doc2Vec
cv_rlts = model_selection.cross_validate(model, doc2vecs, train.target, scoring=scoring, cv=cv_trial_num, return_train_score=True)
for i in range(cv_trial_num):
    s = pd.Series(['Doc2Vec', cv_rlts['train_accuracy'][i], cv_rlts['test_accuracy'][i], cv_rlts['fit_time'][i]], index=df_compare.columns, name='Doc2Vec'+str(i))
    df_compare = df_compare.append(s)

# SCDV
cv_rlts = model_selection.cross_validate(model, scdvs, train.target, scoring=scoring, cv=cv_trial_num, return_train_score=True)
for i in range(cv_trial_num):
    s = pd.Series(['SCDV', cv_rlts['train_accuracy'][i], cv_rlts['test_accuracy'][i], cv_rlts['fit_time'][i]], index=df_compare.columns, name='SCDV'+str(i))
    df_compare = df_compare.append(s)

plt.figure(figsize=(12,5))
sns.boxplot(data=df_compare, y='name', x='valid_accuracy', orient='h', palette='viridis', linewidth=0.5, width=0.5)
plt.grid()
plt.title('validation accuracy')
plt.show()

横軸が分類精度を表しており、SCDVだけ精度が頭一つ抜けている様子がわかります。
誤差でたまにBoWやDoc2Vecに劣ることもあるようですが、全体的には精度が上がっているように見受けられます。

BoWが割と良いというのが意外だったり。
可視化ではだいぶ潰されてしまったように見えましたが、潰されたベクトルに良い感じな軸があったのかもしれません。

まとめ

今回は、文章を数値表現に変換する手法である「文章ベクトル」について触れ、特に、SCDVという手法に焦点を当て、その実装方法を紹介しました。
SCDVは、単語の分散表現を考慮して文書をベクトル化することで、意味的な情報をより豊かに表現できる利点があります。

また、他の文章ベクトル手法としてBoW、TF-IDF、Word2Vec、Doc2Vecを挙げ、それらとSCDVの比較を行いました。
論文同様、今回も20newsコーパスデータにて、分類精度の向上が見られました。

もちろん、この辺りは最適な手法は、扱うタスクやデータによって異なるため、使い分けることが重要です。
実装も難しくありませんし、データに対して様々な文章ベクトルを試して判断すると良いと思います。