BoW 特徴量に対するロジスティック回帰分析の過学習
ここ数回の記事で『言語処理 100 本ノック』の第 8 章、ロジスティック回帰による極性分析の問題に取り組みましたが、正則化無しでも交差検定での正解率があまり変わらないという結果が得られました*1。今回は、このことについて詳細に調べてみます。
データの準備
前回までの記事では PHP で独自に実装したロジスティック回帰を用いましたが、今回は scikit-learn が提供している実装を利用します。パラメータを変えながら実験を繰り返すには、PHP での素朴な実装では計算時間がかかりすぎるためです。
まず全体の準備として、NumPy と pyplot を import します。また、グラフのラベルに日本語を利用できるように FontProperties を作成しておきます。
import numpy as np import matplotlib.pyplot as plt from matplotlib.font_manager import FontProperties fp = FontProperties(fname=r'C:\Windows\Fonts\YuGothic.ttf', size=11)
極性分析用のテキストデータを読み込みます*2。データの一部に非アスキー文字が含まれているので、encoding に latin-1 を指定しました。
posdata = [line.rstrip('\n') for line in open('rt-polarity.pos', 'r', encoding='latin-1')] negdata = [line.rstrip('\n') for line in open('rt-polarity.neg', 'r', encoding='latin-1')]
データの内容を簡単に確認します。正例の先頭の 5 行を表示させてみます。
posdata[0:5]
結果は以下のとおりです。
['the rock is destined to be the 21st century\'s new " conan " and that he\'s going to make a splash even greater than arnold schwarzenegger , jean-claud van damme or steven segal . ', 'the gorgeously elaborate continuation of " the lord of the rings " trilogy is so huge that a column of words cannot adequately describe co-writer/director peter jackson\'s expanded vision of j . r . r . tolkien\'s middle-earth . ', 'effective but too-tepid biopic', 'if you sometimes like to go to the movies to have fun , wasabi is a good place to start . ', "emerges as something rare , an issue movie that's so honest and keenly observed that it doesn't feel like one . "]
正例と負例を合わせて正解ラベルを用意した後、scikit-learn の train_test_split 関数を用いて学習用データセットとテスト用データセットに分割します。今回は全体の 20% をテスト用に確保しました。random_state は再現性のために乱数の seed を固定したもので、数値には特に意味はありません。
from sklearn.cross_validation import train_test_split X = posdata + negdata y = [1] * len(posdata) + [0] * len(negdata) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=1)
CountVectorizer クラスを利用して BoW 形式の特徴抽出を行います。マニュアルを確認したところストップワードを指定できるようだったので english を指定してみました。
from sklearn.feature_extraction.text import CountVectorizer cv = CountVectorizer(encoding='latin-1', stop_words='english') X_train_cv = cv.fit_transform(X_train) X_test_cv = cv.transform(X_test)
確認のため、単語の出現頻度の分布を描画してみます。学習データセットを対象としてヒストグラムを作成しました。
plt.hist(np.squeeze(np.asarray(np.sum(X_train_cv, axis=0))), bins=50) plt.title(u'学習データセットの単語出現頻度の分布', fontproperties=fp) plt.xlabel(u'出現頻度', fontproperties=fp) plt.ylabel(u'単語数', fontproperties=fp) plt.yscale('log') plt.ylim(8e-1, 1e5) plt.show()
ロジスティック回帰モデルによる極性分析
作成したデータにロジスティック回帰モデルを適用して極性分析の実験を行います。LogisticRegression クラスを利用します。まず、最適化ソルバーとコストを受け取り正解率を計算する calc_accuracies 関数を作成しました。max_iter の値は、後述する範囲のコストで計算が収束するように恣意的に設定したものです。
from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score def calc_accuracies(solver, cost): lr = LogisticRegression(solver=solver, max_iter=10000, C=cost) lr.fit(X_train_cv, y_train) y_train_pred = lr.predict(X_train_cv) y_test_pred = lr.predict(X_test_cv) return [accuracy_score(y_train, y_train_pred), accuracy_score(y_test, y_test_pred)]
作成した関数を用いて正解率を計算します。最適化ソルバーに sag (Stochastic Average Gradient descent) を指定して*3、コストは 10^-6 から 10^6 までの範囲で指数部を 0.2 ずつ変化させました。コストは正則化項の係数の逆数に相当するので、コストが大きいほど過学習しやすくなります。
costs = [pow(10, i) for i in np.arange(-6, 6.1, 0.2)] accuracies = np.matrix([calc_accuracies('sag', c) for c in costs])
計算結果を描画します。コストパラメータを大きく設定したとき、学習データの正解率は 100% に近づいており過学習している様子を確認できますが、テストデータの正解率は 72% あたりで横ばいになっており、学習データに対して過学習しつつも一定の汎化能力を維持できていることを示しています。この結果は、PHP の実装で前回確認したものとほぼ一致しています。過学習によってテストデータの正解率は右下がりになるだろうと思っていたので、私にとっては意外な結果でした。
plt.plot(costs, accuracies[:,0], label='Training') plt.plot(costs, accuracies[:,1], label='Test') plt.title(u'コストパラメータによる正解率の変化 (sag)', fontproperties=fp) plt.xlabel(u'コスト', fontproperties=fp) plt.ylabel(u'正解率', fontproperties=fp) plt.legend() plt.xscale('log') plt.xlim(costs[0], costs[-1]) plt.ylim(0.5, 1) plt.show()
LIBLINEAR による実験
次に、LogisticRegression の最適化ソルバーを liblinear に変更して同じ実験を行います。liblinear は LogisticRegression クラスの既定の設定です。
costs = [pow(10, i) for i in np.arange(-6, 6.1, 0.2)] accuracies = np.matrix([calc_accuracies('liblinear', c) for c in costs])
先ほどと同様に計算結果を描画します。プログラムは省略しますが、結果は次のとおりです。LIBLINEAR では SAG の場合とは異なり、過学習によってテストデータの正解率が落ちていくという結果になりました。その一方で、LIBLINEAR ではコストを小さくしても (正則化項の係数を大きくしても) 70% 程度の正解率を維持できているようです*4。
学習結果の比較
パラメータの違いが学習結果に与える影響を確認するため、代表的なパラメータをいくつか選択して、改めてモデルを学習します。sag と liblinear のそれぞれについて、コストを 10^-6 (高バイアス), 10^-0.6 (良い), 10^6 (高バリアンス) として学習しました。
def train(solver, cost): lr = LogisticRegression(solver=solver, max_iter=10000, C=cost) lr.fit(X_train_cv, y_train) return lr costs = [pow(10, i) for i in [-6, -0.6, 6]] # high-bias, good, high-variance lr_sag = [train('sag', c) for c in costs] lr_lin = [train('liblinear', c) for c in costs]
学習結果から、重みの大きな単語を表示させてみます。以下は sag でコストを 10^-0.6 とした場合の実行例です。
features = cv.get_feature_names() wc = np.sum(X_train_cv, axis=0) def print_top_n_words(lr, n, negative=False): sort_order = 1 if negative else -1 sorted_idx = np.argsort(lr.coef_)[0,::sort_order] for i in sorted_idx[:n]: print('%16s\t%f\t%4d' % (features[i], lr.coef_[0,i], wc[0,i])) print_top_n_words(lr_sag[1], 10)
結果は次のとおりです。左から、単語、重み、出現頻度です。
powerful 1.113147 37 enjoyable 1.057301 53 solid 1.046076 50 warm 1.043495 28 entertaining 1.038387 98 touching 0.993614 40 performances 0.993271 149 engrossing 0.981329 24 unexpected 0.964950 19 heart 0.963876 106
コストを変えると、学習結果は大きく変わります。以下は、左が 10^-6 での結果、右が 10^6 での結果です*5。コスト小 (正則化項が大きい) では高頻度語に高い重みが与えられ、コスト大ではその逆の傾向があることが読み取れます。
film 0.000080 1280 taut 15.092569 7 best 0.000039 203 liberating 13.974338 3 performances 0.000038 149 remarkable 11.096390 27 heart 0.000031 106 tape 10.901090 1 love 0.000029 191 serviceable 10.873075 4 world 0.000028 123 warm 10.780220 28 life 0.000028 212 engrossing 10.483317 24 funny 0.000027 245 despicable 10.477185 2 entertaining 0.000025 98 unexpected 10.360805 19 fun 0.000023 131 heartwarming 10.275091 14
グラフを作成して全体の様子を眺めてみます。描画結果は、左がコード例のとおりに 1e-0.6 で実行したもの、右は 1e+6 で実行したものです。全体的な傾向としても、コストが大きな場合に低頻度語に重みが偏る様子を確認できました*6。
plt.scatter(wc, lr_sag[1].coef_, 1, marker='.') plt.title(u'単語の出現頻度と重みの分布 (sag, C=1e-0.6)', fontproperties=fp) plt.xlabel(u'出現頻度', fontproperties=fp) plt.ylabel(u'重み', fontproperties=fp) plt.xlim(9e-1, 2e+3) plt.xscale('log') plt.show()
さて、最適化ソルバーを liblinear にした実験では異なる結果が得られていましたので、比較のため同様に各単語の重みを確認します。コストを 10^-0.6 としたときの結果が以下です。このコストでは sag の場合と似た重みが得られています。
powerful 1.113019 37 enjoyable 1.057044 53 solid 1.045898 50 warm 1.043301 28 entertaining 1.038027 98 touching 0.993489 40 performances 0.992962 149 engrossing 0.981152 24 unexpected 0.964856 19 heart 0.963650 106
コストを 10^-6, 10^6 としたときの結果は以下のとおりです。コストが 10^6 の場合に、学習される重みベクトルが sag と大きく異なっている様子がわかります。
film 0.000080 1280 taut 28.132811 7 best 0.000039 203 demand 25.624158 2 performances 0.000038 149 crowdpleaser 25.316732 1 heart 0.000031 106 schaeffer 25.301165 4 love 0.000029 191 liberating 25.298221 3 world 0.000028 123 moist 24.340107 1 life 0.000028 212 skillful 24.266720 2 funny 0.000027 245 apocalypse 23.973220 1 entertaining 0.000025 98 town 23.276383 6 fun 0.000023 131 despicable 23.105512 2
コストを 10^6 とした場合について、グラフを描画してみます。左側は先ほどの sag でのグラフを再掲したもので、右側が liblinear での学習結果です。縦軸のスケールが違うため少し分かりにくいのですが、形状として liblinear の方が高頻度語に向けて狭まっている様子が見えるでしょうか。
各単語の重みが sag と liblinear でどのように異なっているのかを散布図で見てみます。まず、次のコードで出現頻度が 1 の単語をプロットします。
idx = np.where(wc == 1)[1] plt.scatter(lr_sag[2].coef_[0,idx], lr_lin[2].coef_[0,idx], 1, marker='.', edgecolors='none', c='k') plt.title(u'出現頻度が 1 の単語に与えられた重みの比較 (C=1e+6)', fontproperties=fp) plt.xlabel('sag') plt.ylabel('liblinear') plt.xlim(-10, 10) plt.ylim(-20, 20) plt.show()
以下のような結果が得られました。正の相関が見えるのは自然な結果ですが、中央付近に尖っている部分があります。これは、sag では重みが 0 に近い単語でも liblinear では大きな重みが与えられていることを示しています。
出現頻度が 2 の単語、3 以上の単語について同様にプロットしてみます*7。先ほどのグラフで尖っていた部分が目立たなくなっています。このことから、過学習が発生するようなパラメータ設定での挙動として、liblinear の方がより低頻度語に偏った重み付けをしていると言えそうです。
sag と liblinear でテストデータの正解率が異なっていた理由として、コストを大きくしたときに liblinear の方が低頻度語をより積極的に学習するようになり、結果として汎化能力を持つ単語を十分に学習できないまま反復計算を終えてしまうことが考えられます。これを直接的に確認する方法は思いつかなかったのですが、学習データから低頻度語を除外したものの正解率を計算することで、傍証は得られそうです。
最初の実験結果で見たように、sag と liblinear のいずれの場合でも、コストを大きくして過学習させたときには学習データに対しては 100% 近い正解率が得られていました。低頻度語に依存したモデルであるほど、それらの単語を除外することで正解率が大きく落ちるはずです。モデルが学習データの低頻度語に依存していることは、そのモデルが汎化能力を持たないことを意味するので、これを調べることでテストデータの正解率の違いを説明できそうです。
以下のプログラムでグラフを描画しました。この実装では学習データを操作する代わりに、学習済みのモデルの coef_ を書き換えて同等の処理を行っています。学習データは巨大な疎行列で、その内容を書き換える処理は計算負荷が高いためです。
accs_sag = [] accs_lin = [] for i in range(0, 21): lr_sag[2].coef_[0, np.where(wc == i)[1]] = 0 lr_lin[2].coef_[0, np.where(wc == i)[1]] = 0 accs_sag.append(accuracy_score(y_train, lr_sag[2].predict(X_train_cv))) accs_lin.append(accuracy_score(y_train, lr_lin[2].predict(X_train_cv))) plt.plot(range(0, 21), accs_sag, marker='.', label='sag') plt.plot(range(0, 21), accs_lin, marker='.', label='liblinear') plt.title(u'低頻度語の重みを 0 としたときの学習データの正解率 (C=1e+6)', fontproperties=fp) plt.xlabel(u'閾値 (出現頻度が n 以下の単語の重みを 0 とする)', fontproperties=fp) plt.ylabel(u'正解率', fontproperties=fp) plt.legend() plt.ylim(0.5, 1) plt.show()
実行結果は以下のとおりです。予想したとおり、liblinear で学習されたモデルの方が低頻度語の除去に対する正解率の減少幅が大きく、より低頻度語に依存したモデルが得られていたことが分かりました*8。
*1:『言語処理 100 本ノック』に PHP で挑む (問題 78 ~ 79) - y_uti のブログ
*2:データの入手方法は『言語処理 100 本ノック』のウェブサイトに記載されています。
*3:提供されているソルバーの中で、素朴な最急降下法に近そうなものを選びました。
*4:こちらの原因も気になりますが、今回はあまり深く追えませんでした。
*5:これは各々の実行結果を paste コマンドで並べたものです。
*6:コストを 1e-6 とした学習結果では、高頻度語の方に向けて広がった散布図が得られます。
*7:実装は、先頭行の where に指定する条件を変えるだけです。
*8:グラフの右端の方では、最初の実験で得られたテストデータの正解率よりも低い値になっています。これは、本来必要な汎化能力を持つ単語まで落としてしまっていることを示しています。