2018年12月03日

言語処理100本ノックでPython入門 #79 - 機械学習、scikit-learnで適合率-再現率&グラフの描画

  

いよいよ言語処理100本ノック 2015の第8章・機械学習の最後の問題です。

■ 問題

79. 適合率-再現率グラフの描画
ロジスティック回帰モデルの分類の閾値を変化させることで,適合率-再現率グラフを描画せよ.

■ どう解いたか

久しぶりにmatplotlibを利用します。

標準では、閾値が0.5より大きいか、小さいかで+1にする-1にするのかを判断しているので、この値を変化させることで、適合率-再現率がどう変化するのかを調べる問題です。

scikit-learnには、sklearn.metrics.precision_recall_curveという関数が用意されていて、これを使うと、閾値を少しずつ変化させ、その閾値毎の適合率(precision)と再現率(recall)を計算することができます。

precision, recall, threshold = precision_recall_curve(y_test, pred_positive)
thresholdはその閾値の値のリストになっています。

この値を使って、matplotlibでグラフを描画します。

これについては、

scikit-learnのロジスティック回帰で特定のクラスに分類されやすくする

を参考にさせていただきました。 ただ、僕はnumpyが全く分かってません。というかPythonがまだよくわかっていないことがわかりました。
np.argmin(np.abs(thresholds - (i * 0.05)))

この thresholds - (i * 0.05) って文法的に何を意味しているのだろう。 thresholdsというのがarrayだったら、型が違うからエラーになると思ったんだけど、エラーになりません。arrayに対して マイナス記号で引き算すると、各要素の値に対して減算されるって、規定されてるのかな?

それともscikit-learnのどこかで、- 記号が overloadされてるんだろうか?  あるいは、numpyのクラスで、 - 記号が overloadされてるんだろうか?  

まだまだ知らないことがたくさんありそうです。 numpyのことをもう少し勉強する必要がありそうです。

話を戻すと、ある値の絶対値をとって、その最小値のインデックスを求めているんだから、 たぶん、各リスト内容の各要素に対して、N - (i*0.05) を計算してるんだろうと思います。 そうすれば、閾値にもっとも近いものを取り出せるってことなんだと理解。

実行した結果のグラフ(最後に示します)を見てみると、再現率を上げれば、適合率が下がり、適合率を上げれば、再現率は下がるという関係になっています。 閾値をどこにするかは、とても難しい問題です。

■ Pythonのコード
import re
from nltk import stem
import numpy as np
from sklearn.metrics import precision_recall_curve
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.externals import joblib
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt

class Stopwords:
    words = [ \
        'a', 'about', 'all', 'an', 'and', 'any', 'are', 'as', \
        'at', 'be', 'been', 'but', 'by', 'can', 'could', 'do', \
        'does', 'for', 'from', 'has', 'have', 'he', 'her', 'his', \
        'how', 'i', 'if', 'in', 'into', 'is', 'it', 'its', 'made', \
        'make', 'may', 'me', 'my', 'no', 'not', 'of', 'on', 'one', \
        'or', 'out', 'she', 'should', 'so', 'some', 'than', 'that', \
        'the', 'their', 'them', 'there', 'then', 'they', 'this', \
        'those', 'to', 'too', 'us', 'was', 'we', 'what', 'when',\
        'which', 'who', 'with', 'would', 'you', 'your', ''
        ]

    @staticmethod
    def exists(word):
        return word in  Stopwords.words

class SentimentFeatures:
    def __init__(self):
        self.stemmer = stem.PorterStemmer()
        self.validreg = re.compile(r'^[-=!@#$%^&*()_+|;";,.<>/?]+$')
        self.splitreg = re.compile(r'\s|,|\.|\(|\)|\'|/|\'|\[|\]|-')

    def isValid(self, word):
        if word == '' or len(word) <= 2:
            return False
        if self.validreg.match(word):
            return False
        return not Stopwords.exists(word)

    def getFromLine(self, line):
        array = self.splitreg.split(line)
        # こういう時はlambda キーワードいらないんですね。
        words = filter(self.isValid, array)
        xs = map(self.stemmer.stem, words)
        return xs

    def enumerate(self, filename, encoding):
        with open(filename, 'r', encoding=encoding) as fin:
            for line in fin:
                sentiment = line[:3]
                yield sentiment, self.getFromLine(line[3:])

class SentimentAnalyser:
    def __init__(self):
        self.cv = CountVectorizer(encoding='utf-8')
        self.lr = LogisticRegression(solver='sag', max_iter=10000)

    # LogisticRegression を使い学習する
    def fit(self, X_train, y_train):
        X_train_cv = self.cv.fit_transform(X_train)
        self.lr.fit(X_train_cv, y_train)

    # LogisticRegression を使い予測する
    def predict(self, X_test):
        x = self.cv.transform(X_test)
        return self.lr.predict(x)

    # 予測し、分類毎に確率を得る
    def predict_proba(self, X_test):
        x = self.cv.transform(X_test)
        return self.lr.predict_proba(x)

    # 学習済みデータをロードする
    def load(self):
        self.cv = joblib.load('chapter08/cv73.learn')
        self.lr = joblib.load('chapter08/lr73.learn')

    # 学習済みデータを保存する
    def save(self):
        # 学習したデータを保存する
        joblib.dump(self.cv, 'chapter08/cv73.learn')
        joblib.dump(self.lr, 'chapter08/lr73.learn')

    # 学習に利用するデータを取り出す
    # y[] は、センチメント
    # X[] は、素性データ
    @staticmethod
    def getFeatureData(filename):
        X = []
        y = []
        sf = SentimentFeatures()
        for sentiment, features in sf.enumerate(filename, 'utf-8'):
            y.append(1.0 if sentiment[0] == '+' else 0.0)
            X.append(' '.join(features))
        return X, y

# 参考URL http://ohke.hateblo.jp/entry/2017/08/25/230000

def main():
    sa = SentimentAnalyser()
    X, y = sa.getFeatureData('chapter08/sentiment.txt')
    # 5分割交差検定を行うためにデータを分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)

    # ベクターに変換
    #X_train_cv = sa.trainToVector(X_train)
    #X_test_cv = sa.testToVector(X_test)

    # 学習
    sa.fit(X_train, y_train)

    # 予測
    pp = sa.predict_proba(X_test)
    print(pp[:, 1])
    print()
    print(pp[:5, 1])

    # +1の予測確率を取り出す  [:, 1] は、1列目のすべてのデータ
    pred_positive = sa.predict_proba(X_test)[:, 1]

    # ある閾値の時の適合率、再現率, 閾値の値を取得
    precisions, recalls, thresholds = precision_recall_curve(y_test, pred_positive)

    # 0から1まで0.05刻みで○をプロット
    for i in range(21):
        close_point = np.argmin(np.abs(thresholds - (i * 0.05)))
        plt.plot(precisions[close_point], recalls[close_point], 'o')

    # 適合率-再現率曲線
    plt.plot(precisions, recalls)
    plt.xlabel('Precision')
    plt.ylabel('Recall')

    plt.show()
    input()

if __name__ == '__main__':
    main()


■ 実行結果

スクリーンショット 2018-11-23 16.50.03



■ 最後に

言語処理100本ノック 2015
は、第9章、第10章が残っているんですが、すこし興味を持続するのが難しくなってきました。 ということで、この「言語処理100本ノック 2015」のシリーズはいったん、終了したいと思います。

まあ、最初はPython全くわからなかったけど、なんとかここまで来て、Pythonの知識も随分と増えたかなと思います。でも、使わないとすぐ忘れてしまうんだろうなとは思います。

以前やった Haskell とかは完全に忘却のかなたです(笑)

...

ところで、livedoorブログっていつになったら、https化するんでしょうね。ちょっと待ちくたびれた感があります。

markdown使えないのも不便だし...

14年間続けてきたけど、他に移ろうかな〜。Qiita一本でもいいかなとも感じてます。







Posted by gushwell at 22:05│Comments(0)