y_uti のブログ

統計、機械学習、自然言語処理などに興味を持つエンジニアの技術ブログです

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()

f:id:y_uti:20170120004213p:plain

ロジスティック回帰モデルによる極性分析

作成したデータにロジスティック回帰モデルを適用して極性分析の実験を行います。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()

f:id:y_uti:20170120005609p:plain

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
f:id:y_uti:20170120010825p:plain

学習結果の比較

パラメータの違いが学習結果に与える影響を確認するため、代表的なパラメータをいくつか選択して、改めてモデルを学習します。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()

f:id:y_uti:20170121082141p:plain:w320 f:id:y_uti:20170121082147p:plain:w320

さて、最適化ソルバーを 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 の方が高頻度語に向けて狭まっている様子が見えるでしょうか。
f:id:y_uti:20170121082147p:plain:w320 f:id:y_uti:20170121085642p:plain:w320

各単語の重みが 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 では大きな重みが与えられていることを示しています。
f:id:y_uti:20170121115506p:plain

出現頻度が 2 の単語、3 以上の単語について同様にプロットしてみます*7。先ほどのグラフで尖っていた部分が目立たなくなっています。このことから、過学習が発生するようなパラメータ設定での挙動として、liblinear の方がより低頻度語に偏った重み付けをしていると言えそうです。
f:id:y_uti:20170121115933p:plain:w320 f:id:y_uti:20170121115941p:plain:w320

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
f:id:y_uti:20170121144506p:plain

*1:『言語処理 100 本ノック』に PHP で挑む (問題 78 ~ 79) - y_uti のブログ

*2:データの入手方法は『言語処理 100 本ノック』のウェブサイトに記載されています。

*3:提供されているソルバーの中で、素朴な最急降下法に近そうなものを選びました。

*4:こちらの原因も気になりますが、今回はあまり深く追えませんでした。

*5:これは各々の実行結果を paste コマンドで並べたものです。

*6:コストを 1e-6 とした学習結果では、高頻度語の方に向けて広がった散布図が得られます。

*7:実装は、先頭行の where に指定する条件を変えるだけです。

*8:グラフの右端の方では、最初の実験で得られたテストデータの正解率よりも低い値になっています。これは、本来必要な汎化能力を持つ単語まで落としてしまっていることを示しています。