読者です 読者をやめる 読者になる 読者になる

TadaoYamaokaの日記

山岡忠夫 Home で公開しているプログラムの開発ネタを中心に書いていきます。

TF-IDFのword2vecによる改良

先日doc2vecで気象庁のFAQの質問文を使って、類似質問文の検索を行ったが、質問文が短い場合うまくいかなかった。
質問文が短い場合は、TF-IDFのような古典的手法の方が有効であった。

しかし、TF-IDFには以下のような欠点がある。

  1. 語順が失われる
  2. 高次元
  3. 単語の距離に意味がない(意味が同じでも別の単語となる)
  4. 単語が完全に一致している必要がある(類似語では検索できない)

word2vecを使うと、2.、3.と4.の弱点を補うことができるが、文の単語をword2vecでベクトル化してそれを合計するだけでは、TF-IDFの利点である頻度に対する重み付けができない。

そこで、word2vecでベクトル化した単語のベクトルの各成分をそれぞれ単語の数と見做して、TF-IDFと同じように重み付けを行うことを試みる。

TF-IDFの単語数は離散的な数で1単語は、その単語成分の1に相当する。
word2vecでベクトル化すると1単語が、固定次元のベクトルの各成分に分散される。値は連続的な量となる。

そのため、TF-IDFの計算式を以下のように置き換える。

元のTF-IDFの定義

\displaystyle
\begin{eqnarray*}
tfidf_{i,j} &=& tf_{i,j} \cdot idf_{i} \\
tf_{i,j} &=& \frac{n_{i,j}}{\sum_k n_{k,j}} \\
idf_{i} &=& log{\frac{|D|}{df_i}}
\end{eqnarray*}
iは単語番号、jは文書番号、n_{i,j}は文書jの単語iの数、|D|は総文書数、df_iは単語iが出現する文書の数

置き換えたTF-IDFの定義

\displaystyle
\begin{eqnarray*}
tfidf'_{i,j} &=& tf'_{i,j} \cdot idf'_{i} \\
tf'_{i,j} &=& \frac{n'_{i,j}}{\sum_k n'_{k,j}} \\
idf'_{i} &=& log{\frac{|D|}{df'_i + 1}}
\end{eqnarray*}
iは単語ベクトル番号、jは文書番号、n'_{i,j}は文書jの単語ベクトルiの合計量、|D|は総文書数

df'_iは元の定義では単語iが出現する文書の数だが、単語ベクトルとすると各成分に分散されるため全ての成分に値が存在する。
そのため、単語ベクトルiが存在する文書の数を数えることに意味はない。
元の定義と近い意味となるように文書jの単語ベクトルiの最大値で代替する。

また、n'_{i,j}をそのまま単語ベクトルの合計とすると、単語ベクトルは負の値もとるため、正の値と負の値を持つ単語があると打ち消しあってしまう。
これは文書の特徴を表す方法として望ましくないため、正の値と負の値でそれぞれ異なるベクトルに分解する。
単語ベクトルの次元が300とすると、TF-IDFの計算で用いる次元は600となる。

正と負の値でベクトルを分解することで、df'_iの値が0になる場合があるので、idf'_{i}の計算の分母に1を加えている。

このように計算することで、ひとつの文書内で多く現れる単語ベクトル成分は重みが大きくなり、全文書に頻繁に表れる単語ベクトル成分については重みが小さくなる。

Pythonによる実装

from gensim.models.wrappers.fasttext import FastText
from sklearn.metrics.pairwise import cosine_similarity
import MeCab
import zenhan
import numpy as np
import argparse

DIM = 300

parser = argparse.ArgumentParser()
parser.add_argument("input", type=str)
parser.add_argument("model", type=str)
parser.add_argument("--dictionary", "-d", type=str, help="mecab dictionary")
args = parser.parse_args()

mecab = MeCab.Tagger("" if not args.dictionary else "-d " + args.dictionary)

def wakati(str):
    words = []
    for line in mecab.parse(zenhan.z2h(str, mode=3).lower()).split("\n"):
        cols = line.split("\t")
        if len(cols) >= 2:
            c = cols[1].split(",")
            if not c[0] in ["助詞", "助動詞", "副詞", "記号"] and not c[1] in ["非自立", "代名詞"]:
                words.append(cols[0])
    return words

questions_src = []
questions = []
answers = []
for line in open(args.input, "r", encoding="utf-8"):
    cols = line.strip().split('\t')
    questions_src.append(cols[0])
    questions.append(wakati(cols[0]))
    answers.append(cols[1])

model = FastText.load_fasttext_format(args.model)

def part_minus(v):
    # 正と負で別のベクトルにする
    tmp_v = np.zeros(DIM*2)
    for i in range(DIM):
        if v[i] >= 0:
            tmp_v[i] = v[i]
        else:
            tmp_v[i*2] = -v[i]
    return tmp_v

questions_vec = []
tf_vecs = []
df_vec = np.zeros(DIM*2)
for question in questions:
    vec = np.zeros(DIM*2)
    maxvec = np.zeros(DIM*2)
    for word in question:
        try:
            word_vec = part_minus(model[word])
            vec += word_vec
        except:
            continue
        maxvec = np.maximum(word_vec, maxvec)
    tf_vecs.append(vec / sum(vec))
    df_vec += maxvec

idf_vec = np.log(len(questions) / (df_vec + 1))
tfidf_vecs = []
for tf_vec in tf_vecs:
    tfidf_vecs.append(tf_vec * idf_vec)

while True:
    line = input("> ")
    if not line:
        break

    words = wakati(line)
    vec = np.zeros(DIM*2)
    for word in words:
        try:
            vec += part_minus(model[word])
        except:
            continue
    tf_vec = vec / sum(vec)

    sims = cosine_similarity([tf_vec * idf_vec], tfidf_vecs)
    index = np.argsort(sims[0])
    #print(" ", words)
    print(questions_src[index[-1]], sims[0, index[-1]])
    print(questions_src[index[-2]], sims[0, index[-2]])
    print(questions_src[index[-3]], sims[0, index[-3]])
    print(questions_src[index[-4]], sims[0, index[-4]])
    print(questions_src[index[-5]], sims[0, index[-5]])
    print()

精度に悪影響があるため、分かち書きする際、助詞、助動詞、副詞、非自立動詞、代名詞は除外している。
単語のベクトル化には、先日の日記Wikipediaから学習したfastTextの学習モデルを使用する。

類似文をコサイン類似度が高いものから上位5つを表示するようにしている。

実行結果

気象庁のFAQを使って類似質問文を検索すると以下のようになった。

> みぞれとは何ですか?
「みぞれ」とは何ですか? 1.0
沖縄で雪やみぞれが降ったことはありますか? 0.811335703068
雪は降水量としても観測されていますか? 0.664184433827
テレビの天気予報で表示される「上空○○mで氷点下○○℃の寒気」の地図は気象庁ホームページに掲載されていますか? 0.662642825833
雨が降っていないのに、大雨警報が発表されているのはなぜですか? 0.661427889065

> 強風とは何ですか?
「暴風」、「非常に強い風」は、どのくらいの風速のことを指すのですか? 0.684477560199
暴風や高潮などが発生する前から警報・注意報が発表されている場合があるのはなぜですか? 0.67050842838
冬や春に日本付近で急速に発達する低気圧を台風と呼ばないのはなぜですか? 0.666651376246
台風は中心に近いほど雨が強いのですか? 0.662830709102
温帯低気圧は暴風警戒域や強風域の発表がなく、被害範囲が不確定なので、台風とは別の表現に変えるべきではないですか? 0.660758980316

> 七色
虹は何色ですか? 0.616076177425
大雨や大雪の大規模災害がほとんどない地域に対応するため、例えば「数十年に一度」に続いて「これまで経験したことの無い」などの表記を基準に加えてはいかがですか? 0.607886497747
太陽の横に虹が出ていたのですが、これはどういった現象でしょうか? 0.607067195122
日の出・日の入り、月齢を知りたいのですが?晴れている夜空に光、隕石、火球を見たのですが? 0.600948941332
雲が七色に見える「彩雲」の仕組みは何ですか? 0.600899151826

> 異常気象
「異常気象」の定義はあるのですか? 0.843976195565
世界各地で熱波などの異常気象が発生していますが、地球温暖化と関係があるのですか? 0.786703283525
気象庁ホームページでは、冷夏、暖冬といった予報をどこで知ることができますか? 0.656454299309
エルニーニョ現象(ラニーニャ現象)が発生すると、日本近海の海面水温はどのようになるのですか? 0.648840912345
暴風や高潮などが発生する前から警報・注意報が発表されている場合があるのはなぜですか? 0.641740460418

> 花粉情報
特別警報の発表単位は、警報・注意報と同様に市町村単位となるのですか。また、浸水害、土砂災害の種別はあるのですか? 0.605165529987
発表区域が市町村単位とのことですが、その場合、県内の全市町村が伊勢湾台風級で統一できるのですか。県内一律に特別警報の基準とする根拠は何ですか? 0.60469986924
大雨や大雪の大規模災害がほとんどない地域に対応するため、例えば「数十年に一度」に続いて「これまで経験したことの無い」などの表記を基準に加えてはいかがですか? 0.592209619199
震度6弱以上は、「特別警報」に位置づけられていますが、発表時は従来の緊急地震速報(警報)と変わらない点など位置づけを明確にすべきではないですか? 0.590251936495
発表のタイミングは、津波の発生が確認された時点とすべきではないですか?また、基準については、「沖合津波計の観測データにより」を頭に加えるなどしてはいかがですか? 0.588381434639

> 花粉情報 気象庁
花粉情報は気象庁で発表していますか? 0.899127962588
気象庁ではどのような仕事をしていますか? 0.825064678275
気象庁で微小粒子状物質(PM2.5)に関する情報を発表していますか? 0.821742022196
緊急地震速報や気象等に関する特別警報を知らせる緊急速報メールが携帯電話に届いたのですが、気象庁からの情報ですか? 0.807086027913
気象庁で放射能に関する情報を発表していますか? 0.802376682125

> 気象予報士 観測
予報官は気象予報士の資格を持っていますか? 0.842685846819
アメダス観測データやウィンドプロファイラの観測データを気象データセットとして入手したいのですが? 0.785383318417
研究・教育のために行う気象の観測とは、どのようなものを指すのですか 0.763022367632
気象庁ホームページで過去の観測データや平年値を閲覧できますか? 0.759997897669
震度6弱以上を「特別警報」に位置づけることの意義は何ですか?予想震度にも誤差がある状況なので、観測体制が整い精度が向上してからでよいのではないですか? 0.755638615804

「みぞれとは何ですか?」は、質問文と同じ文であるため、その質問文がヒットしている。

「強風とは何ですか?」では、「強風」という単語を含まなくても類似語を含む文がヒットしている。
「七色」でも同様に「虹」を含む文がヒットしている。
「異常気象」に対して、エルニーニョ現象の質問文も上位に表示されている。

逆に、「花粉情報」では、「花粉情報」そのものを含む文がヒットしていない。
意味が近い単語が多く含まれている文がヒットしていると思われるが、そのものの単語がヒットしなくなるのは単語をベクトル化して無理やりTF-IDFで計算した弊害が現れている。
「花粉情報」「気象庁」の2語では正しく検索できている。

気象予報士」「観測」では、全文書で頻度の少ない単語「気象予報士」が優先されている。TF-IDFによる重みが有効に働いている。

以上のように、word2vecのベクトル成分に重み付けすることで、類似語を使用しても類似文の検索ができるようになった。
ただし、単語をベクトル化したことで、単語そのものを含まない文もヒットするようになるため弊害もある。
場合によってはTF-IDFと併用した方がよいかもしれない。

追記

無理やりTF-IDFを単語ベクトルに適用しているので、どれくらいの精度になっているかはちゃんとした評価が必要だと思う。
評価には再現率と適合率を確認する必要があるが、適切な評価用データを持ち合わせていないので宿題としておく。