自然言語処理に触れてみる(メルカリのデータセットでカテゴリ分類)

よく聞く自然言語処理をやっとこさ触ってみることにした。

自然言語処理自体については図書館で借りた戦略的データサイエンスという本がちょっとだけ触れていてくれたのでこれを元に触ってみることにする
www.oreilly.co.jp

今までSVMでも決定木でも学習に必要な特徴量は大体離散値か実数値で、文字列を特徴量として渡したことがない。画像も数値の集まりで表現したし、正解ラベルも分類ならdogは1、catは2とか離散値かone-hot表現に直した

よくあるニュースの説明文からカテゴリ分類をするといったものも、説明文を数値、特徴ベクトルに変換して学習している。

その変換アプローチとして簡単かつ低コストなのがbag-of-word(BoW)と呼ばれるもの。これは全てのドキュメントを独立した単語の集まりと見なす(文法、単語の順序、文構造、句読点とかは無視する)
このアプローチではドキュメントに含まれる単語は全てドキュメントにとって重要なキーワード(特徴)になり得るとみる

あるドキュメントにおける特徴値はどんなものになるかというと全ての単語をトークンとして扱って、あるドキュメントにそのトークンが含まれているかどうかをベクトルで表現する。

用語の出現するか否かの区別に加えて、用語が使用された回数でさらに区別をしようということで用語の出現頻度を数える。ドキュメント内に存在する用語の重要性が出現回数に応じて高くなるときとかの重み付けに有効らしい。用語出現頻度(Term Frequency,TF)と呼ばれる

長いドキュメントの方が多くの単語を持っているから用語出現頻度の値は長いドキュメントの方が大きいはず。でも長いドキュメントだからといって短いドキュメントよりある用語が重要な意味を持つとは限らない

そのため、用語出現頻度がドキュメントの長さに依存しないようにドキュメント内の総単語数で用語の出現回数を割るといった正規化を施すこともあるらしい。

自前で雑に実装したもの

import collections
import re

def TF(desc,terms,norm=False):
    tf_dict = {}
    d = desc.lower()
    for term in terms:
        i = 0
        for m in re.finditer(term,d):
            i += 1
        if i > 0 and norm==False:
            tf_dict[term] = i
        elif i > 0 and norm==True:
            tf_dict[term] = i/len(set(d.split(' ')))
    return tf_dict

正規化の方はsetでドキュメントの長さを取ってしまったけど重複ありならいらなかったかもしれない・・・

頻度のリストを作る際に、まず用語に以下のような処理を施してあげるのが基本

  • 大文字と小文字の正規化 単語は全て小文字に変換する。これはJsonJSONといった単語を同じ用語と見なすため

  • ステミング
    英単語において動詞announceannouncesannouncedannouncingと変化する。これらはannouc + 接頭辞に分解できるため、接頭辞を削除してannoucという用語として扱うといったことをする。名詞も複数形は単数形に直してあげる

  • ストップワードの除去
    ストップワードとは英語において出現回数の多い一般的な単語のことを指す。これらは自然言語処理の対象外になる単語だそうだ。theとかamとかandとかそこらへん。最近でたItとかいうホラー映画のタイトルみたいにストップワードが重要な意味を持つこともあるから対象によって除去は変わってきそうだ


この前処理はgensimという便利なライブラリのgensim.parsing.preprocess_stringというメソッドを使うことで上記の処理を一気にすることができる

pre_itemname = []
# gensim.parsing.preprocess_string(文字列)は以下の関数を実行した結果を返してくれる
# strip_tags() : <a>hoge</hoge>といったhtmlのタグを消してくれる
# strip_punctuation() : ,や!といった記号を消してくれる
# strip_multiple_whitespaces() : \rや\nといった空白文字を消してくれる
# strip_numeric() : 文字列の中にある数字を消してくれる
# remove_stopwords() : am や but といったストップワードと呼ばれる単語を削除してくれる
# strip_short() : 一定の長さを持たない単語を削除する(デフォルトでは3)
# stem_text() : ステミング(useやuseful,usingなら -> us)を行ってくれる
for i in itemname:
    pre_itemname.append(gensim.parsing.preprocess_string(i))

データとしてkaggleのコンペにメルカリの価格予測のデータを使うことにする。規約見た限り大丈夫なはず。
f:id:Owatank:20180204161831p:plain

処理の結果としては以下のようになった
f:id:Owatank:20180206112721p:plain
どことなく重要な単語が消えてるようなそうでないような・・・まあいいや

自分で実装したTFを適用してみる。結果はこうなった
f:id:Owatank:20180206113305p:plain
商品のタイトルだから単一ドキュメントに重複する単語が登場するのは滅多にないかな

単一のドキュメント内での用語の出現頻度を測定することとは別に解析対象のコーパス(ドキュメントの集まり)において、ある用語がどれだけ一般的かといったものも測定する必要がある。

例えば、ある用語が出現するドキュメントの数が少ないほど、その用語は重要かもしれない。ある用語tに関する希少性は逆文書頻度(IDF)と呼ばれる式で計算できる

{ \displaystyle IDF(t) = 1+log_{2}\frac{総ドキュメント数}{用語tを含むドキュメント数} }

logの中身から、分母(用語tを含むドキュメント数)が小さいほど、IDFの値は大きくなることがわかる

まずこれを計算するにはコーパスからある用語がいくつ出現しているかを調べなくてはいけない

def get_terms_dict(doc):    
    d = collections.defaultdict(int)
    for i in doc:
        # 重複はカウントしない
        terms = set(i)
        for term in terms:
            d[term] += 1
    return d

コーパスでの用語出現頻度のリストを作ってIDFを計算する

def IDF(terms_dict,term,doc_num):
    return (1+np.log2(doc_num/terms_dict[term]))

f:id:Owatank:20180206114931p:plain

TFとIDFを組み合わせたTFIDFと呼ばれるテキストの表現方法(重み付けとも)が有名らしい

{ \displaystyle TFIDF(t,d) = TF(t,d)\ast IDF(t) }

tは用語、dは単一ドキュメントを指す。数式から、TFIDFは単一ドキュメントに対する値であることがわかる。
自分で実装したもの

def TFIDF(tf,idf):
    tfidf_dict = {}
    tf_list = list(tf.items())
    for t in tf_list:
        tmp_dict = {}
        for k,v in t[1].items():
            tmp_dict[k] = v*idf[k]
        tfidf_dict[t[0]] = tmp_dict
    return tfidf_dict

f:id:Owatank:20180206120226p:plain
TFの値を正規化していないからか、基本的に1や2とかの値が多いため、IDF値のまんまな物が多い・・・

こうすることで用語が登場しているかどうかの0と1の特徴ベクトルではなく重み付けを行ったもので学習データとして使うことができる
    -= ∧_∧
  -=≡ ( ´∀`)
    -=( つ┯つ
   -=≡/  / //
  -=≡(__)/ )
   -= (◎) ̄))

gensimのライブラリを使ってさっきのTFIDFの値が合っているか、その後学習データでカテゴリの分類をしてみる

まずコーパス内での用語出現回数リストは次のように作成する

itemname_corpora_dict = gensim.corpora.Dictionary(pre_itemname)

TFIDF値を計算するには先にドキュメント(前処理済み)をBoWに変換してから行う

bow_dict = {}
for i in range(0,len(pre_itemname)):
    bow_dict[i] = itemname_corpora_dict.doc2bow(pre_itemname[i])

tfidf_model = gensim.models.TfidfModel(bow_dict.values(),normalize=False)
tfidf_corpus = tfidf_model[bow_dict.values()]

自分で作ったTFIDFのものと結果を比較する。適当に2個持ってきた結果
f:id:Owatank:20180206122854p:plain

なんかgensimの方はIDFの計算において1が加算されていないっぽい?そんなことしたらlogの中身が1のとき0の値になるけどいいんだろうか
多分計算式としてはそれ以外は合ってるかな

この重み付けされたBoWのベクトル(商品の名前)で、カテゴリ分類をしてみる。

コーパス内にある用語の数(次元数)が19797と多いので次の考え方を考慮して絞り込みをする

  • 用語はレアすぎてはいけないということ
    解析対象のコーパスのどっかのドキュメント内に滅多に使われない単語が登場しているとする。この用語が重要かどうかは利用次第で重要度は変わってくる。クラスタリングのためならあまり使われない単語は分類の基準にはなれず重要度は低くなる。なので、ある用語が出現するドキュメントの数に下限を設定して、その加減を超えたものを用語として扱うといった制限を付ける。

  • 用語は一般的すぎてもいけないということ
    ストップワードのようにコーパスにある全てのドキュメントに出現するような用語は分類、クラスタリングといった点では区別に使えない。上とは反対にある用語が出現するドキュメントの数(または割合)に任意の上限を設けて、過度に出現する用語を取り除く

# レアすぎる単語、一般的すぎる単語の除去
item_corpora_dict.filter_extremes(no_below=3,no_above=0.5)

実行した結果、コーパス内にある用語の数は7174となった。それでもまだ多い気がする
これは一種の次元削減として見てもいいのかな

次にTFIDFを正規化ありで実行し、重み付けされた特徴ベクトルを得る

tfidf_model = gensim.models.TfidfModel(bow_dict.values(),normalize=True)
tfidf_corpus = tfidf_model[bow_dict.values()]

feature_vec = gensim.matutils.corpus2dense(list(tfidf_corpus), num_terms=len(itemname_corpora_dict))
feature_vec = feature_vec.T

分類のための学習を行う

# TFIDFで重み付けしたもので分類
from sklearn.grid_search import GridSearchCV
from sklearn.cross_validation import StratifiedKFold
from sklearn.linear_model import SGDClassifier
clf = GridSearchCV(SGDClassifier(penalty="l2"), param_grid={'alpha':[0.01,0.05,0.001,0.0001]})

# StratifiedKFold(y, n_folds, shuffle=True) 交差検証用にデータを分割してくれる
for i, (itr, ite) in enumerate(StratifiedKFold(y_test,n_folds=5, shuffle=True)):
    clf.fit(X_train[itr], y_train[itr])
    train_pred = clf.predict(X_train[ite])
    train_acc = metrics.accuracy_score(train_pred,y_train[ite])
    print("{0} finished. test accuracy : {1}".format(i+1,train_acc))

今回はGridSearchCVStratifiedKFoldというメソッドを使ってみた。前者は最適なパラメータを探してくれて、後者は交差検証のためのメソッドらしい

f:id:Owatank:20180206143448p:plain

最終的なテストデータでのAccuracy0.78095だった。

同様のことを重み付けしていないただのBoWでのベクトルで行なったが、こっちでのテストデータのAccuracy0.7827だった。
重み付けしていない方が誤差レベルだけど値が大きいことからここでの問題に対して用語の重み付けはそんなに意味を成さなかったのかな(((((;`Д´)≡⊃)`Д)、;'.・

今回やったもののコードはここ

github.com