2016年7月11日

TF-IDFでデータベース内の類似テキストを検索する Part 1 (基本機能編)

最近、「TF-IDF」と呼ばれる手法を使ってPostgreSQL内に保存されたテキストの類似度を計算して、似ているテキストを検索する方法を試していました。

一通り目途が立った気がしてきましたので、今回から3回に渡ってその方法をご紹介します。
  • Part 1: 基本機能編
  • Part 2: 実践編
  • Part 3: 性能改善編
Part 1 は基本機能編ということで、TF-IDF に基づく類似性検索を PostgreSQL 内部で実装する方法をご紹介します。

Part 2 は実践編として、大量のドキュメントをPostgreSQLに格納して、TF-IDF を計算して検索するまでを解説します。

Part 3 は性能改善編ということで、検索性能を改善する方法を検討します。

■「TF-IDF」とは


TF-IDFは、自然言語処理で使われるアルゴリズムで、文書やコーパス(文書群)の中における単語の出現頻度を用いて、文書の単語に重み付けをする手法です。
TF-IDFでは、「Term Frequency」と呼ばれる「単一の文書中における単語の出現頻度」と、「Inverse Document Frequency」と呼ばれる「コーパス全体における単語の出現頻度(の逆数)」を組み合わせて、単語に重み付けをします。
  • TF: 特定の単語の出現回数 / 文書全体の単語数
  • IDF: log(全文書数 / 単語を含んでいる文書数) + 1
として定義され、これらを組み合わせて「TF-IDF」が「TF * IDF」として計算されます。

このTF-IDFを用いて、文書中に出現する個々の単語に対して重み付けがされることになります。

詳細は、Wikipediaなど関連資料を参照ください。

TF-IDFを用いることによって、単語、文書、コーパスに含まれる情報を定量化することができ、文書の類似度の計算などができるようになります。

■処理の概要


今回の目的は、PostgreSQL内に蓄積された文書群(コーパス)に対して、特定の文書をクエリとして与えて、似ているドキュメントを取得できるようにする、というものです。

以下は、今回の処理と設計の概要です。
  • 日本語のテキストを形態素解析を使って分かち書きをする。(テキストを入力、トークンを出力とする関数として実装)
  • 分かち書きの結果を使って、各単語のTFを計算する(トークンを入力、TFを出力とする関数として実装)
  • 計算したTFの結果を使って、IDFを計算する。(TFを入力、IDFを出力とする集約関数として実装)
  • 計算したTFとIDFを使って、各文書のTF-IDFを計算する。(TFとIDFを入力、TF-IDFを出力とする関数として実装)
  • 2つの文書のTF-IDFを入力として類似度を計算する。(2つのTF-IDFを入力、ユークリッド距離を出力とする関数として実装)

■動作環境


今回の動作確認環境は以下の通りです。
  • CentOS 6
  • Python 2.7
  • PostgreSQL 9.5 (w/ PL/Python)
  • scikit-learn 0.17.1
  • mecab-python 0.996
PL/Pythonは、Python 2.7に対応したものを用意しています。CentOS/RHEL 6系のPostgreSQLは、デフォルトではOSバンドル版のPython 2.6系のPL/Pythonとなりますのでご注意ください。詳細は以下のエントリをご参照ください。

■日本語を形態素解析して分かち書きする


まず、入力された日本語のテキストを分かち書き(形態素解析)してトークンに分解する関数を作成します。形態素解析には Mecab を使います。

Mecab および mecab-python については、以下のエントリを参照してください。
以下のように、分かち書きの関数 mecab_tokenize() を作成し、TEXT で与えた文章を品詞に分解して JSONB (文字列の配列)として返却するように実装します。
snaga=> SELECT mecab_tokenize('今日の天気は晴れです。');
                   mecab_tokenize
----------------------------------------------------
 ["今日", "の", "天気", "は", "晴れ", "です", "。"]
(1 row)

snaga=>
mecab_tokenize 関数の定義は以下のようになります。
CREATE OR REPLACE FUNCTION mecab_tokenize(string text)
  RETURNS jsonb
AS $$
    import MeCab
    import json
    import plpy
    import sys

    tokens = []
    m =  MeCab.Tagger("-Ochasen")

    """
    Mecabに渡すためにはunicodeではなくutf-8である必要がある。
    Mecabから戻ってきたらunicodeに戻す。

    また、Mecabはエンコード済みのutf-8文字列へのポインタを返すので、
    on-the-flyでutf-8に変換するのではなく、変数として保持しておく
    必要がある。(でないとメモリ領域がGCで回収されてデータが壊れる)

    参照:
    http://shogo82148.github.io/blog/2012/12/15/mecab-python/
    """
    enc_string = string
    node = m.parseToNode(enc_string)
    while node:
        n = {}
        n['surface'] = node.surface.decode('utf-8')
        n['feature'] = node.feature.decode('utf-8').split(",")
        t = n['feature'][6]
        if t == '*':
            t = n['surface']
        if len(t) > 0:
            tokens.append(t)
        node = node.next
    return json.dumps(tokens)
$$
LANGUAGE 'plpython2u';

■TFを計算する


次に、トークンの配列を受け取って、TF(文書内での単語の出現率)を計算する関数を作成します。

以下のように、JSONBの配列を入力として受け取ると、各単語ごとのTFを計算してJSONB型で返却します。
snaga=> SELECT tf('["今日", "の", "天気", "は", "晴れ", "です", "。"]'::jsonb);

                           tf

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
(1 row)

snaga=>
この例の場合は、ドキュメントが7つのトークンから構成され、各品詞がすべて1回しか出現していないため、各単語のTFはすべて 1/7 、つまり 0.14 となります。

TF を計算する関数は以下の通りです。先ほど分かち書きした結果を JSONB で受け取り、渡されたすべてのトークン(つまり文書)の中での出現頻度を計算して、トークンをキーとして、出現頻度をバリューとして持つ、Key-Value構造の JSONB で返却します。
CREATE OR REPLACE FUNCTION tf(tokens jsonb)
  RETURNS jsonb
AS $$
    import json
    import plpy
    import re
    import sys

    tf = {}
    _tokens = json.loads(tokens)
    _count = len(_tokens)
    for t in _tokens:
        if t in tf:
            tf[t] += 1.0
        else:
            tf[t] = 1.0
    for t in tf:
        tf[t] = tf[t]/_count
    return json.dumps(tf)
$$
LANGUAGE 'plpython2u';

■IDFを計算する


TF を計算したら、次は IDF を計算します。 IDF はコーパス(文書群)全体におけるある単語の出現する文書数(の逆数の対数)ですので、先に集計した TF を使って計算します。
snaga=> \d t1
     Table "public.t1"
 Column | Type  | Modifiers
--------+-------+-----------
 tf     | jsonb |

snaga=> SELECT * FROM t1;
                                                                                                  tf

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
 {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気":0.14285714285714285, "晴れ": 0.14285714285714285}
 {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
(3 rows)

snaga=> 
上記の例のように複数のドキュメントの TF があった場合に、これらの TF を集約処理する集約関数 idf() として実装します。この関数では、各単語がいくつの文書に出てくるかを計算し、その逆数の対数を計算して返却します。
snaga=> SELECT idf(tf) FROM t1;
                                                                                                                 idf

--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"。": 1.0, "の": 1.0, "は": 1.0, "僕": 2.09861228866811, "雨": 2.09861228866811, "です": 1.0, "今日": 2.09861228866811, "天気": 1.4054651081081644, "明日": 2.09861228866811, "晴れ": 1.4054651081081644, "気分": 2.09861228866811}
(1 row)

snaga=>
例えば、上記の例の「天気」という単語は全3文書中2つの文書で出てくるので、その IDF は log(3/2) + 1 = 1.405 となります。

PL/Pythonで集約関数を実装する方法については、以下のエントリを参照してください。
今回の idf() 集約関数のコードは以下の通りです。
CREATE OR REPLACE FUNCTION _idf(s jsonb, n jsonb)
  RETURNS jsonb
AS $$
    import json
    import plpy
    global s

    if s is None:
       s = "{\"count\": 0, \"df\": {}}"
    agg = json.loads(s)
    doc_freq = agg['df']
    doc = json.loads(n)
    for w in doc:
        if w in doc_freq:
           doc_freq[w] += 1.0
        else:
           doc_freq[w] = 1.0
    agg['count'] += 1.0
    return json.dumps(agg)
$$
LANGUAGE 'plpython2u';

CREATE OR REPLACE FUNCTION _idf_final(s jsonb)
  RETURNS jsonb
AS $$
    import json
    import math
    import plpy
    global s

    a = json.loads(s)
    dc = a['count']
    df = a['df']
    for t in df:
        df[t] = math.log(dc/df[t]) + 1.0
    return json.dumps(df)
$$
LANGUAGE 'plpython2u';

CREATE AGGREGATE idf(jsonb)
(
    sfunc = _idf,
    stype = jsonb,
    finalfunc = _idf_final
);

■TF-IDFを計算する


最後に、ここまで計算してきたTFとIDFを使ってTF-IDFを計算する関数を作成します。

以下のように TF があり、
snaga=> SELECT docid,tf FROM t1;
 docid |                                                                                                  tf
-------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
     1 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
     2 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
     3 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
     4 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "昨日": 0.14285714285714285, "晴れ": 0.14285714285714285}
(4 rows)

snaga=> 
また、以下のように IDF があった場合に、
snaga=> SELECT idf(tf) FROM t1;
                                                                                                                                 idf
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"。": 1.0, "の": 1.0, "は": 1.0, "僕": 2.386294361119891, "雨": 2.386294361119891, "です": 1.0, "今日": 2.386294361119891, "天気": 1.2876820724517808, "明日": 2.386294361119891, "昨日": 2.386294361119891, "晴れ": 1.2876820724517808, "気分": 2.386294361119891}
(1 row)

snaga=>
これらを JSONB 型の入力として受け取って、TF-IDF を JSONB 型で返却します。
snaga=> SELECT docid,tf_idf(tf, (SELECT idf(tf) FROM t1)) FROM t1;
 docid |                                                                                                                              tf_idf
-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
     1 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
     2 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
     3 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.34089919444569866, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.0, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.34089919444569866}
     4 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.34089919444569866, "晴れ": 0.18395458177882582, "気分": 0.0}
(4 rows)

snaga=>
計算方法は、単に同じ品詞の TF と IDF を掛け合わせるだけです。

tf_idf 関数の実装は以下の通りです。
CREATE OR REPLACE FUNCTION tf_idf(tf jsonb, idf jsonb)
  RETURNS jsonb
AS $$
    import json
    import math
    import plpy

    _tf = json.loads(tf)
    _idf = json.loads(idf)
    terms = list(set(_tf.keys() + _idf.keys()))

    tf_idf = {}
    for t in terms:
        if t in _tf:
            tf1 = float(_tf[t])
        else:
            tf1 = float(0)
        if t in _idf:
            idf1 = float(_idf[t])
        else:
            idf1 = float(0)
        tfidf = tf1 * idf1
        tf_idf[unicode(t)] = tfidf

    return json.dumps(tf_idf)
$$
LANGUAGE 'plpython2u';

■TF-IDFを用いて2つの文書の類似度を算出する


最後に、計算したTF-IDFを2つ受け取って、それらの類似度を計算する関数を作成します。

例えば、「今日の天気は晴れです。」という文章と「明日の天気は雨です。」という2つの文章のTF-IDFがJSONBで与えられたとき、
snaga=> SELECT docid,t,tfidf FROM t1;
 docid |           t            |      tfidf
-------+------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
     1 | 今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
     2 | 明日の天気は雨です。   | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
(2 rows)

snaga=>
以下のようにユークリッド距離を返します。
snaga=> SELECT euclidean_distance('{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}'::jsonb, '{"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}'::jsonb);
 euclidean_distance
--------------------
  0.618446497668635
(1 row)

snaga=>
ユークリッド距離を計算する関数 euclidean_distance 関数は以下のように実装します。

2つの文書の TF-IDF のセットを受け取って各品詞ごとに並べ替えて、品詞ごとの TF-IDF の値を並べたベクトルを2つ作成し、その2つのベクトルのユークリッド距離を計算します。ユークリッド距離の計算は scikit-learn に用意されている euclidean_distances 関数を使います。
CREATE OR REPLACE FUNCTION euclidean_distance(tfidf_a jsonb, tfidf_b jsonb)
  RETURNS float8
AS $$
    from sklearn.metrics.pairwise import euclidean_distances
    import json

    aa = json.loads(tfidf_a)
    bb = json.loads(tfidf_b)
    w = list(set(aa.keys()).union(set(bb.keys())))
    vec_a = []
    vec_b = []
    for t in w:
        if t in aa:
            vec_a.append(aa[t])
        else:
            vec_a.append(0)
        if t in bb:
            vec_b.append(bb[t])
        else:
            vec_b.append(0)
    distance = euclidean_distances([vec_a], [vec_b])
    return distance[0][0]
$$
LANGUAGE 'plpython2u';

■動作確認


それでは全体を通して簡単に動作確認をしてみます。

まずは、テキストを格納するテーブルを作成し、サンプルとなるテキストを INSERT します。
snaga=> CREATE TABLE t1 (
snaga(>   docid serial primary key,
snaga(>   t text,
snaga(>   tf jsonb,
snaga(>   tfidf jsonb
snaga(> );
CREATE TABLE
snaga=> \d t1
                         テーブル "public.t1"
  列   |   型    |                       修飾語
-------+---------+----------------------------------------------------
 docid | integer | not null default nextval('t1_docid_seq'::regclass)
 t     | text    |
 tf    | jsonb   |
 tfidf | jsonb   |
インデックス:
    "t1_pkey" PRIMARY KEY, btree (docid)

snaga=> INSERT INTO t1 (t) VALUES ('今日の天気は晴れです。'),
snaga->                       ('明日の天気は雨です。'),
snaga->                       ('僕の気分は晴れです。'),
snaga->                       ('昨日の天気は晴れです。');
INSERT 0 4
snaga=> SELECT * FROM t1;
 docid |           t            | tf | tfidf
-------+------------------------+----+-------
     1 | 今日の天気は晴れです。 |    |
     2 | 明日の天気は雨です。   |    |
     3 | 僕の気分は晴れです。   |    |
     4 | 昨日の天気は晴れです。 |    |
(4 行)

snaga=> 
次に、各テキストごとの TF を計算して、 tf カラムに JSONB 形式で保存します。
snaga=> UPDATE t1 SET tf = tf(mecab_tokenize(t));
UPDATE 4
snaga=> SELECT t,tf FROM t1;
           t            |                                                                                                  tf

------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "今日": 0.14285714285714285, "天気": 0.14285714285714285, "晴れ": 0.14285714285714285}
 明日の天気は雨です。   | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "雨": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "明日": 0.14285714285714285}
 僕の気分は晴れです。   | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.14285714285714285, "です": 0.14285714285714285, "晴れ": 0.14285714285714285, "気分": 0.14285714285714285}
 昨日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "です": 0.14285714285714285, "天気": 0.14285714285714285, "昨日": 0.14285714285714285, "晴れ": 0.14285714285714285}
(4 行)

snaga=> 
次に、今計算した TF を使って IDF を計算し、TF と IDF を使って TF-IDF を計算、 tfidf カラムに保存します。(ここでは、IDF の計算をサブクエリで行い、それを使って一気に TF-IDF を計算しています。)
snaga=> UPDATE t1 SET tfidf = tf_idf(tf, (SELECT idf(tf) FROM t1));
UPDATE 4
snaga=> SELECT t,tfidf FROM t1;
           t            |                                                                                                                               tfidf

------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 今日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.34089919444569866, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.0}
 明日の天気は雨です。   | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.34089919444569866, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.34089919444569866, "昨日": 0.0, "晴れ": 0.0, "気分": 0.0}
 僕の気分は晴れです。   | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.34089919444569866, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.0, "明日": 0.0, "昨日": 0.0, "晴れ": 0.18395458177882582, "気分": 0.34089919444569866}
 昨日の天気は晴れです。 | {"。": 0.14285714285714285, "の": 0.14285714285714285, "は": 0.14285714285714285, "僕": 0.0, "雨": 0.0, "です": 0.14285714285714285, "今日": 0.0, "天気": 0.18395458177882582, "明日": 0.0, "昨日": 0.34089919444569866, "晴れ": 0.18395458177882582, "気分": 0.0}
(4 行)

snaga=> 
最後に、テキスト「今日の天気は晴れです。」をクエリのテキストとして、その他のテキストとのユークリッド距離を計算します。
snaga=> SELECT docid,t,euclidean_distance(tfidf, (SELECT tfidf FROM t1 WHERE docid = 1)) FROM t1;
 docid |           t            |  euclidean_distance
-------+------------------------+----------------------
     1 | 今日の天気は晴れです。 | 1.05367121277235e-08
     2 | 明日の天気は雨です。   |    0.618446497668635
     3 | 僕の気分は晴れです。   |    0.618446497668635
     4 | 昨日の天気は晴れです。 |     0.48210426418717
(4 行)

snaga=>
ここまでで、PostgreSQL のテキストの TF-IDF を計算して、クエリのテキストとのユークリッド距離を計算する動作確認は完了になります。

■まとめ


今回は、TF-IDF を使ってテキストの類似度検索をするために必要な関数群を実装してみました。

このような関数群を UDF として PostgreSQL 内部に実装することで、PostgreSQL 内に保存されたデータを分析のために PostgreSQL から取り出す必要がありませんし、PL/Python を使うことによって scikit-learn などの Python のエコシステムを活用できるようになります。

次回は、今回作成した関数を使って、より大きなドキュメントを PostgreSQL に保存して、類似度に基づいて検索してみます。

では。

0 件のコメント:

コメントを投稿