【ロジスティック回帰の思わぬ罠!】学習結果の発散とその回避方法
ロジスティック回帰
気軽に使えるクラス分類の基本的なツール。
しかし、油断しているとうっかりはまってしまう落とし穴があります。 うまく学習率を調整しても、学習結果が発散する場合があるという罠です。
今回の記事ではこの罠とその回避方法についてご紹介します!
Logistic回帰とは
Logistic回帰については前回の記事
にて紹介いたしました。 簡単に言ってしまうと、Logistic回帰とはモデル
に基づいて、特徴からクラスを識別する手法です。
特にこの識別では特徴空間に超平面を張ることによって線形にクラスを識別します。
計算の方法も容易で多数のパッケージが提供されています!
Logistic回帰の罠
Logistic回帰は非常に扱いやすい識別器でありますが、実は油断しているととんでもない罠にはまります。 ここではその一例をPythonを使ってご紹介します。
今回使うライブラリはこちら:
# library import numpy as np import matplotlib.pyplot as plt
とりあえず、numpy
とmatplotlib
にとどめておきましょう。
で、ここで問題が生じるデータは次のようにして作ります!
num_of_data = 10000 # 特徴ベクトル X = np.random.randn(num_of_data,2) # 教師ラベル C = (X@np.ones(2)>0).astype(dtype=np.int32) # 特徴ベクトルにバイアスに関する項を加える Xb = np.hstack([X, np.ones((num_of_data,1))])
これはこんな感じのデータ。ちょうどを境界とするようなデータですね!
plt.scatter(X[C==1, 0], X[C==1, 1], c="r", label="label 1", alpha=0.25) plt.scatter(X[C==0, 0], X[C==0, 1], c="b", label="label 0", alpha=0.25) plt.legend() plt.xlabel("X1") plt.ylabel("X2") plt.show()
そして、Logistic回帰に関する前回の記事
に従って、このデータに対してLogistic回帰を適用してみると...
def sigmoid(a): return 1.0/(1.0 + np.exp(-a)) eta = 0.01 num_of_learning = 10000 w = np.zeros((num_of_learning, 3)) w[0,:] = np.random.randn(3) for n in range(1, num_of_learning): # Gradient Descent Method w[n,:] = w[n-1,:] - eta * Xb.T @ (sigmoid(Xb@w[n-1,:])-C) print("result : ", w[-1,:])
result : [90.91425211 91.22758037 0.10616866]
なるほど。 少し係数が大きいような気もしないでもないですが... ま、目的の係数の定数倍程度なのでよしとしましょう!
の学習回数ごとの推移を見てみましょう。Parameter 0,1,2がそれぞれw[0]
, w[1]
, w[2]
に対応しています。
for i in range(w.shape[1]): plt.plot(w[:,i], "-", label="parameter {0}".format(i), lw=9-4*i) plt.xlabel("iteration") plt.ylabel("parameter") plt.legend() plt.show()
おやおや、まだ係数は収束してなさそうですね...
もう少し学習を回してみましょう。
eta = 0.01 num_of_learning = 100000 w = np.zeros((num_of_learning, 3)) w[0,:] = np.random.randn(3) for n in range(1, num_of_learning): # Gradient Descent Method w[n,:] = w[n-1,:] - eta * Xb.T @ (sigmoid(Xb@w[n-1,:])-C) print("result : ", w[-1,:])
result : [1.92573126e+02 1.93011457e+02 1.04457667e-01]
for i in range(w.shape[1]): plt.plot(w[:,i], "-", label="parameter {0}".format(i), lw=9-4*i) plt.xlabel("iteration") plt.ylabel("parameter") plt.legend() plt.show()
なるほど。まだまだ増加して発散しそうですね...
ところで、ロスはどうなっているんでしょう?
def crossentropy(x,y): return -x[y>0]@np.log(y[y>0])-(1-x[y<1])@np.log(1-y[y<1]) plt.plot(1+10*np.arange(w[::10].shape[0]), np.array([crossentropy(C,sigmoid(Xb@v)) for v in w[::10]])/num_of_data, "ko-") plt.xscale("log") plt.yscale("log") plt.xlabel("iteration") plt.ylabel("loss(cross-entropy)") plt.show()
学習を重ねても減少していて収束してなさそうですね...学習データに過剰にフィッティングしていることを疑って、別のデータを新たに生成しても...
num_of_new_data = 100 # 特徴ベクトル newX = np.random.randn(num_of_data,2) # 教師ラベル newC = (X@np.ones(2)>0).astype(dtype=np.int32) # 特徴ベクトルにバイアスに関する項を加える newXb = np.hstack([X, np.ones((num_of_data,1))]) plt.plot(1+10*np.arange(w[::10].shape[0]), np.array([crossentropy(newC,sigmoid(newXb@v)) for v in w[::10]])/num_of_data, "ko-") plt.xscale("log") plt.yscale("log") plt.xlabel("iteration") plt.ylabel("loss(cross-entropy)") plt.show()
学習データでも検証データでも、学習回数ごとにロスは正しく減少しています。 このままでは学習結果のが発散してしまいます...💦
一体何が起こっているのでしょうか?
原因の解説
実はこの問題はデータが持っている性質「線形分離可能性」によって生じています。
いやいや、線形分離可能性って何やねんって話ですね笑
ということでまずは線形分離可能性についてお話します。
線形分離可能性
線形分離可能性とは、その名が示す通りデータを線形に分離できることを指します。 つまり、特徴空間内の2つのクラスのデータをある超平面によって分離することができるということです。
例えば、二次元平面上でクラス1のデータは全て第一象限に、クラス0のデータは全て第三象限にあったとしましょう(境界線は含みません)。
このとき、これらのデータは
という直線(超平面に対応)によって分離することができます。このような場合をデータが線形分離可能といいます。
一般的な次元において数式を以て線形分離可能性を表現をするのであれば、あると定数が存在して、
が成り立つときに、とクラスの組み合わせである学習データは線形分離可能であると言います。
係数が発散する場合のロス関数
さて、線形分離可能な場合に係数が発散することを簡単な例で確認しましょう!(※危ない議論もありますが...)
今、“無限個”のデータ(に関して独立)があるとして、これが線形分離可能としましょう。 特に簡単のため、特徴は一次元としておき、クラスは決定論的に
によって決まっていて、を満たしているとしましょう。 このとき、ロジスティック回帰でモデル
のを求めてみましょう!(ここでも簡単のためバイアスに相当する項は無視しています)
ロジスティック回帰におけるロス関数は対数尤度、つまりクロスエントロピーで定義されます。 特に今、“無限個”のデータを考えているので、ロス関数は
となります。もう少し詳しく書くと
となります(ここでは標準正規分布の密度関数です)。
このロス関数はグラフを書くと...
となります。 つまり、が大きくなればなるほど、ロス関数がどんどん小さくなっていきます!
実際にをで微分してみると(ちょっと怪しいですが...💦)
となって、常に単調減少であることがわかります。 したがって、この簡単な例ではロス関数を最小化しようとしてを求めたとしても、それよりも大きいの方がさらにロス関数が小さいので、永遠にを大きくしていってしまうという問題が生じます。
ま、もっと簡単に説明するのであれば、この例の場合、実は真のモデルが存在して
となります。 ここではHeavisideのステップ関数で、ロジスティックシグモイド関数でそれを表現しようとするととしなければならないということが簡単な説明ですかね?
以上のようなことが、他の線形分離可能な場合にも生じて、学習結果の係数が発散するという問題が起こっています。
回避方法
このような問題の回避策の1つとしては事前分布を用意することが挙げられます。 これはロジスティック回帰のロス関数(対数尤度)にパラメータの正則化項を導入することに相当します。
このようにすれば正則化項ががあまり大きくならないように働き、の発散を抑えることができるようになります!
例えば、正則化係数の正則化項を一番最初に示した例に適用するのであれば、次のようになります:
num_of_data = 10000 # 特徴ベクトル X = np.random.randn(num_of_data,2) # 教師ラベル C = (X@np.ones(2)>0).astype(dtype=np.int32) # 特徴ベクトルにバイアスに関する項を加える Xb = np.hstack([X, np.ones((num_of_data,1))]) # Logistic回帰の学習 eta = 0.01 # 学習率 lam = 0.1 # 正則化係数 num_of_learning = 10000 # 学習回数 w = np.zeros((num_of_learning, 3)) w[0,:] = np.random.randn(3) for n in range(1, num_of_learning): # Gradient Descent Method w[n,:] = w[n-1,:] - eta * Xb.T @ (sigmoid(Xb@w[n-1,:])-C) - 2*eta*lam*w[n-1,:] print("result : ", w[-1,:]) for i in range(w.shape[1]): plt.plot(w[:,i], "-", label="parameter {0}".format(i), lw=9-4*i) plt.xlabel("iteration") plt.ylabel("parameter") plt.legend() plt.show()
result : [22.59205216 22.51610759 0.15557752]
今度はちゃんと学習した係数が収束しましたね!
これで問題解決です!
まとめ
というわけで今回の記事では、ロジスティック回帰でうっかりしてるとはまってしまう罠についてご紹介いたしました!
学習データ自体が線形分離可能であると、ロジスティック回帰では無限によい識別器を作ることができるので学習結果が発散してしまうという問題です! この回避方法の1つとしては正則化項を加えると割とうまく行くということをご紹介しました。
ロジスティック回帰で発散して何か変だなって思った際には、ぜひ思い出していただければうれしいです!
なお、Pythonの機械学習ツールscikit-learn
のロジスティック回帰ではデフォルトで正則化項がついているので安心です。
また、今回の記事の内容は機械学習のバイブル「Pattern Recognition and Machine Learning」でも触れられていますので、ぜひ興味のある方は一度読んでみるといいと思います!↓
というわけで、とやかく書きましたが、この辺りで執筆を終わりたいと思います。
ご質問やご意見などございましたら、ぜひコメントのほどよろしくお願いします!