線形SVM(Support Vector Machine)を敢えてscipy.optimizeで実装してみた

修論に追われながらですが、気分転換にやってみたので記事にしてみます。

実際に実装してみると`scipy`でSVMを実装している人が少なく、わりとはまることが多かったので知見をシェアしてみます。
この記事は[サポートベクターマシン](http://amzn.asia/d/fDupu6J)という本をベースにしています。この本は間違いが多いのでもし読むなら注意して読むようにしてください。個人的にはあまりおすすめはできません。

※1 サポートベクターマシンについて十分知識があるという前提で話します。ご注意ください。
※2 もっと良いソースの書き方があればぜひ教えてください。

# 線形サポートベクターマシンを最適化問題に落とす

## SVMの双対問題

$x_i$と$y_i$は、それぞれ$x_i = (x_i^{(1)}, \dots, x_i^{(d)}) \in \mathbb{R}^d$, $y_i \in \\{ -1, +1 \\}$となり、$x$は特徴データで、$y$は正解データを指しています。$\alpha = (\alpha_1 , \dots, \alpha_l)^{\mathrm{T}}$は識別超平面を求めるために使うラグランジュ関数のラグランジュ定数とします。

このときSVMの識別超平面$w^{\mathrm{T}}x+b=0$最適化問題の双対問題を考えます。双対問題は、以下のようになります。

$\alpha$について最大化すべき目的関数は

```math
\sum_{i=1}^l\alpha_i - \frac{1}{2}\sum_{i,j=1}^l\alpha_i\alpha_jy_iy_jx_i^{\mathrm{T}}x_j
```

で、その制約条件は、

```math
\alpha_i \ge 0,\quad i = 1, \dots, l
```

```math
\sum_{i=1}^l{\alpha_iy_i} = 0
```

となります。

## 最適化問題の定式化

これを[scipy.optimize.minimize](https://docs.scipy.org/doc/scipy-1.0.0/reference/generated/scipy.optimize.minimize.html)にあるように、最適化問題の定式に落とし込んでいきます。慣習に習って$g \ge 0$を不等式制約、$h = 0$を等式制約としておきます。

```math
\begin{align}
f(\alpha) &= \sum_{i=1}^l\alpha_i - \frac{1}{2}\sum_{i,j=1}^l\alpha_i\alpha_jy_iy_jx_i^{\mathrm{T}}x_j \\
g_i(\alpha_i) &= \alpha_i \ge 0, \quad i = 1, \dots, l \\
h(\alpha) &= \sum_{i=1}^l{\alpha_iy_i} = 0
\end{align}
```

# scipy.optimize.minimizeに解かせる

## scipy.optimize.minimizeの説明
`scipy.optimize.minimize`（以下`minimize`）の使い方をこの記事で使う必要最低限で簡単に紹介します。

```python
from scipy.optimize import minimize

cons = [{"type": "ineq", "fun": g}]
res = minimize(fun, x0, method="SLSQP", constraints=cons)
```

`minimize`の引数`fun`は先程紹介した$f(\alpha)$に対応しています。ここに最適化してほしい式を返すような関数を指定します。

`x0`は初期値です。SVMを扱っている場合では凸最適化問題なのであまり気にせず零ベクトル`np.zeros()`でも入れてあげましょう。

`method`はこの最適化問題のソルバー(=`minimize`)にどのような解放で最適化問題を解いてもらうかを指定します。僕はあまり最適化の知識がないので、とりあえず`"SLSQP"`(=[Sequential Least SQuares Programming(逐次二次計画法)](https://ja.wikipedia.org/wiki/%E9%80%90%E6%AC%A1%E4%BA%8C%E6%AC%A1%E8%A8%88%E7%94%BB%E6%B3%95))を選んでみました。

`constraints`は制約を指定する`dict`または`dict`のシーケンスです。シーケンスというのはリストまたはタプルなどと思っておけばいいです。
`dict`は、辞書型で`"type"`キーに制約の種類（不等式制約、等式制約）を入れ、`"fun"`キーにその制約を表す関数をいれておきます。これも`minimize`の`fun`と同様にすればよいです。

## 目的関数、制約条件の関数化

今回はクロージャを使って作成していますが、本来ならば`minimize`の`args`パラメータなどでどうにかできそうです。
※`minimize`の仕様上、目的関数を最小化するようになっているため、最大化する目的関数の符号を逆転させることで実現しています。

```python
# 目的関数
# x, yはデータで、データを指定して関数を実行すると引数が$\alpha$の関数が返ってくるのでそれを`minimize`に使わせる。
def get_obj(x, y):
    def obj(alpha):
        second = np.array([alpha[i]*alpha[j]*y[i]*y[j]*np.dot(x[i], x[j]) \
                for i in range(len(alpha)) for j in range(len(alpha))])
        return  - np.sum(alpha) + .5 * np.sum(second)
    return obj

# 不等式制約（制約の数だけ作れるようにしていることに注意）
def get_g(i):
    def g(alpha):
        return alpha[i]
    return g

# 等式制約
def get_h(y):
    def h(alpha):
        return np.sum([alpha[i]*y[i] for i in range(len(alpha))])
    return h
```

## `constraints`の値を作成

不等式制約の数だけ関数を作成していることに注意してください。

```python
cons = [
    {'type': 'eq', 'fun': get_h(y)}  # 等式制約
]
for i in range(len(x)):
    cons.append({'type': 'ineq', 'fun': get_g(i)})  # 不等式制約
```

# 最適化問題を解いて、識別超平面を得る

さきほど使ったものをそれぞれ`minimize`に入れて使います。

```python
# 学習データの作成
ONE_DATA = 30
x = np.vstack((np.random.normal(loc=-2, scale=1.0, size=(ONE_DATA, 2)),\
        np.random.normal(loc=2, scale=1.0, size=(ONE_DATA, 2))))
y = np.hstack((np.repeat(-1, ONE_DATA), np.repeat(1, ONE_DATA)))

# ソルバーの実行
res = minimize(get_obj(x, y), np.zeros(len(x)), constraints=cons, method="SLSQP")
```

`minimize`が返す`res`というものは、最適化の実行結果の詳細を持つオブジェクトになっています。`print(res)`するとこうなります。

```python
     fun: -0.13412273818124631
     jac: array([  2.82717060e-01,   3.85807756e-01,   3.13518258e-01,
         2.77660560e-01,   4.86643452e-01,   4.78479423e-01,
         4.67139196e-01,   5.05073734e-01,   2.60325832e-01,
         # 省略
         8.17233417e-01,   2.69528620e-01,   3.99119448e-01,
         6.87747058e-01,   5.80251390e-01,   6.86778139e-01,
         0.00000000e+00])
 message: 'Optimization terminated successfully.'
    nfev: 313
     nit: 5
    njev: 5
  status: 0
 success: True
       x: array([ -4.05360147e-16,   9.85552613e-16,  -2.03652689e-16,
         3.49785386e-16,   6.35631441e-16,  -1.85546533e-16,
         9.43039335e-17,  -1.77270683e-16,  -5.21819014e-16,
　　　　  # 省略
        -3.01016448e-16,  -2.69363170e-17,   7.08757201e-17,
         6.82412540e-16,   9.35886104e-17,  -6.49034939e-17])
```

`res.x`がこの最適化問題の解になります。これが`minimize`が出した最適な$\alpha$ということになります。

主問題から

```math
w = \sum_{i=1}^l\alpha_iy_ix_i
```

が成り立つので、得られた$\alpha$を代入することで最終的な識別境界の式が得られます。
このソルバーの性質上、`0`になるべきところでも完全に`0`にならないため、非常に小さい数字`tol`未満のものはこちらで`0`と置き換えてしまいます。**このしきい値が小さすぎるとサポートベクトルの選定に失敗します**。

```python
lpha_hat = res.x
tol = 1e-10
support_index = np.where(alpha_hat > tol)[0]
non_suppoert_index = np.where(alpha_hat <= tol)[0]
alpha_hat[non_suppoert_index] = 0
# ここでalphaからパラメータの計算
w = np.sum([alpha_hat[i]*y[i]*x[i] for i in range(len(x))], axis=0)
```

最後に、バイアスである`b`を計算します。

```python
# ラベルが-1のサポートベクトルを選出
x_m = x[support_index][np.where(y[support_index]==-1)]
# ラベルが+1のサポートベクトルを選出
x_p = x[support_index][np.where(y[support_index]==1)]
# サポートベクトルを一つだけ採用してbを計算
b = - ( np.dot(w, x_p[0]) + np.dot(w, x_m[0]) ) / 2
```

そして、図示をして終了です。

```python
# 二次元の識別超平面＝直線のyに関する式を計算
def classifier(w1, w2, b, x):
    return -w1/w2*x - b/w2

# データ点の描画
for xi, yi in zip(x, y):
    # サポートベクトルの描画
    # 条件としてはモレがあるが、ほぼモレは起こりえないのでこのまま。
    if (xi[0] in x_m and xi[1] in x_m) or \
            (xi[0] in x_p and xi[1] in x_p):
        plt.plot(xi[0], xi[1], 'gv')
        continue
    # ラベルごとに記号を分ける
    if yi == 1:
        plt.plot(xi[0], xi[1], 'ro')
    else:
        plt.plot(xi[0], xi[1], 'bx')
x = np.linspace(-5, 5, 100)
y = classifier(w[0], w[1], b, x)
plt.plot(x, y)
plt.xlim([-5, 5])
plt.ylim([-5, 5])
plt.show()
```

最終的に
![figure_1-2.png](https://qiita-image-store.s3.amazonaws.com/0/202772/7026e66e-98b5-ddee-2326-b800e37eb67f.png)
このような図が得られます。

試しに線形分離が不可能な場合でこれを実行してみると、プログラムが終了しなくなります。主問題の制約条件が成立する候補がなくなってしまうからでしょうか。

ちなみに今回はあえて双対問題でやってみましたが、主問題でやっても同じような結果が得られるかと思います。

これから趣味でやったことで調べてもあまり情報がないものは日記にしておこうかなという思いでやってみましたがなかなか楽しかったです。
研究のやる気が戻ってきたので、それではまた。

[ソースはこちらから(gist](https://gist.github.com/konabe/81fe048630ff3cb0dc2965788676a12b#file-linear_svm-py)