スパース(疎)なデータを非スパースに変換して、XGBoostを高速化

 
 機械学習のモデルを作るときは、とりあえずXGBoostにしとけばよいでしょっていうぐらい、XGBoostが優秀です。ただし、XGBoostはある程度の精度のモデルを何も考えずに構築できる反面、他の機械学習モデルよりは実行時間が長くなります。モデルの学習時間が長くなると、ハイパーパラメータの探索回数を制限せざる負えなくなり、モデルの精度にも関わってきてしまいます。


 学習データのデータサイズが大きくなるときは、すべてのデータに意味があるというよりは、ほぼ0の値で埋まっているようなスパース(疎)なデータになっていることが多いです。予測モデルにとっては、0列が多いことで精度が上がることはなく、実行時間がその分かかってしまうためよくないです。


 そこで今回は、スパース(疎)なデータを非スパースなデータに変換して学習させることで、学習時間の高速化を行います。これによりモデルの予測精度が下がることはないということも確かめます。本記事のコードを実行すると、以下のようにXGBoostの高速化ができます。今回のコードはこちらにあります。



f:id:dskomei:20210415210351p:plain:w600

 



今回使用するデータ


 まずは今回使用するモジュールを先にインポートし、扱うデータの説明をします。スパースなデータを非スパースなデータに変換するために、scipyモジュールを使います。


モージュルのインポートと準備

from pathlib import Path
import sys
import scipy
import numpy as np
import pandas as pd
import time
import matplotlib.pyplot as plt
import seaborn as sns
import xgboost as xgb
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split, StratifiedKFold

result_dir_path = Path('result')

if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)


使用するデータとデータのスパース化


 今回の実験で扱うデータは、sklearn.datasets.make_classification 関数を使って作ります。これ自体は特に何を使っても大丈夫でしょう。大事なのは、このベースデータを使ってスパースなデータに変換することです。とはいっても、値が0のみの列データを追加するだけですが。

X_data, y_data = make_classification(
    n_classes=4,
    n_samples=10,
    n_features=6,
    n_clusters_per_class=1,
    n_informative=4,
    random_state=42
)

X_data

f:id:dskomei:20210416104300p:plain:w400

y_data

f:id:dskomei:20210416104428p:plain:w400


 上を見てもらえば分かる通り、機械学習モデルの入力データである行列と出力データであるベクトルができています。この入力用データに0列のデータを追加することで、データをスパース化させます。


f:id:dskomei:20210415212651p:plain:w450


データの非スパース化


 今回の核心であるスパースなデータの非スパース化処理を実践します。実は非常に簡単です。これをやるために、まずはスパースなデータを作成します。

X_data, y_data = make_classification(
    n_classes=4,
    n_samples=10,
    n_features=6,
    n_clusters_per_class=1,
    n_informative=4,
    random_state=42
)

data = pd.DataFrame(
    X_data, 
    columns=['X' + str(i + 1) for i in range(X_data.shape[1])]
)

new_0_col = 10

data = pd.concat([
    data,
    pd.DataFrame(
        np.zeros((data.shape[0], new_0_col)), 
        columns=['X' + str(i + data.shape[1] + 1) for i in range(new_0_col)]
    )
], axis=1)
data

f:id:dskomei:20210416104612p:plain:w600


 変数「new_0_col」で指定した分の値が0の列をベースデータに追加しています。スパースなデータになったので、このデータを非スパース化します。そのために、scipyのcsr_matrix 関数を使います。この関数を使えば、ワンライナーで非スパース化できるのです。

data_sparsed = scipy.sparse.csr_matrix(data)


 以上で終わりです。3分クッキングよりも短いですね。この非スパース化したデータの中身を見てみます。

data_sparsed.data

f:id:dskomei:20210416104824p:plain:w500


 上のベクトルは、スパース化する前の行列データを0行目から順に格納していっており、値が0の場合は省かれています。これにより非スパースなベクトルになっているのです。ただこのデータだけでは、各値の元々の行列の場所がわからないため、元の行列に戻せなくなります。そこで、スパース化したデータでは、各値の元の列のインデックスベクトルとそのイデックスベクトルのどこまでが何行目かのデータも持っています。

data_sparsed.indices

f:id:dskomei:20210416104829p:plain:w500


 上のベクトルは、値ベクトルに対応しており、それぞれの値ベクトルがもとの行列の何列目かを保持しています。

data_sparsed.indptr

f:id:dskomei:20210416104835p:plain:w500


 このベクトルは行を管理しており、元の行列の何行目かを、列インデックスベクトルの添字で表しています。例えば、元の行列0行目に対応する列インデックスは data_sparsed.indptr[0] ~ data_sparsed.indptr[1] の範囲であり、元の行列の0行目の値を持ってくる場合は、以下のコードになります。

data_sparsed.data[data_sparsed.indices[
    data_sparsed.indptr[0]: data_sparsed.indptr[1] 
]]

f:id:dskomei:20210416104847p:plain:w500
 

 これでデータの非スパース化は完成です。次からは、非スパース化データにすることで、XGBoostの学習時間が短くなるのかを見ていきます。スパース化/非スパース化による学習時間を比較するために、先にスパースなデータを使いXGBoostの学習時間を計測します。


スパースなデータでのXGBoostの学習時間の計測


 非スパースなデータの作り方がわかったので、そのデータを使うことにより学習時間が短くなるかを確認したいのですが、先にスパースなデータでXGBoostの学習時間がどれだけかかるかを見ておきます。以下のコードでは、ベースデータを10,000と20,000、追加する0列の件数を [0, 10, 100, 1000, 10000] とし、それぞれの設定で交差検証法により複数回学習しています。交差検証法を使っているのは、それぞれの設定での処理時間を平均値にして比較するためです。

n_splits = 5

cv_results = []
for n_samples in [10000, 20000]:

    X_data, y_data = make_classification(
        n_classes=10,
        n_samples=n_samples,
        n_features=100,
        n_clusters_per_class=1,
        n_informative=4,
        random_state=42
    )

    X_data = pd.DataFrame(X_data, columns=['x' + str(i + 1) for i in range(X_data.shape[1])])

    for n_new_0_col in [0, 10, 100, 1000, 10000]:

        X_data_ = pd.concat([
            X_data, 
            pd.DataFrame(
                np.zeros((X_data.shape[0], n_new_0_col)), 
                columns=['x' + str(X_data.shape[1] + i + 1) for i in range(n_new_0_col)]
            )
        ], axis=1)

        X_train, X_test, y_train, y_test = train_test_split(
            X_data_, y_data, test_size=0.3, random_state=42, shuffle=True
        )

        kf = StratifiedKFold(n_splits=n_splits)

        loop = 1
        for indexes_train, indexes_valid in kf.split(X_train, y_train):

            X_train_cv = X_train.iloc[indexes_train]
            y_train_cv = y_train[indexes_train]
            X_valid_cv = X_train.iloc[indexes_valid]
            y_valid_cv = y_train[indexes_valid]

            start_time = time.time()
            model = xgb.XGBClassifier(random_state=42, use_label_encoder=False)
            model.fit(
                X_train_cv, 
                y_train_cv,
                early_stopping_rounds=30,
                eval_set=[(X_valid_cv, y_valid_cv)],
                eval_metric='mlogloss',
                verbose=0
            )
            end_time = time.time()

            score = model.score(X_test, y_test)
            print('Samples: {}, New 0 Col: {}, [{}/{}], Score: {:.0f}%, {:.0f}s'.format(
                n_samples, n_new_0_col, loop, n_splits, score * 100,
                end_time - start_time
            ))

            cv_results.append([n_samples, n_new_0_col, loop, end_time - start_time, score])
            loop += 1

cv_results = pd.DataFrame(
    cv_results, 
    columns=['n_samples', 'new_n_0_col', 'loop', 'elapsed_time', 'score']
)
cv_results.to_csv(result_dir_path.joinpath('model_result_sparse.csv'), index=False)
cv_results

f:id:dskomei:20210416105110p:plain:w500


 上のコードはそこまで難しくないと思います。pd.concatにより、指定した件数の0列を追加してデータをスパース化し、交差検証法により複数回の学習をしています。筆者の環境では、ベースデータ20,000件、追加する0列のデータ10,000件にすると、学習時間は688秒でした。結構な処理時間でしたね。


 次に非スパース化したデータを使ったXGBoostの学習時間を見ていきます。


非スパースなデータでのXGBoostの学習時間の計測


 スパースなままのデータでXGBoostを学習させると、結構な時間がかかることが確認できました。同じ設定で、スパースなデータを非スパース化して学習させて、処理時間を測定します。

n_splits = 5

cv_results = []
for n_samples in [10000, 20000]:

    X_data, y_data = make_classification(
        n_classes=10,
        n_samples=n_samples,
        n_features=100,
        n_clusters_per_class=1,
        n_informative=4,
        random_state=42
    )

    X_data = pd.DataFrame(X_data, columns=['x' + str(i + 1) for i in range(X_data.shape[1])])
    
    for n_new_0_col in [0, 10, 100, 1000, 10000]:

        X_data_ = pd.concat([
            X_data, 
            pd.DataFrame(
                np.zeros((X_data.shape[0], n_new_0_col)), 
                columns=['x' + str(X_data.shape[1] + i + 1) for i in range(n_new_0_col)]
            )
        ], axis=1)

        X_train, X_test, y_train, y_test = train_test_split(
            X_data_, y_data, test_size=0.3, random_state=42, shuffle=True
        )

        kf = StratifiedKFold(n_splits=n_splits)

        loop = 1
        for indexes_train, indexes_valid in kf.split(X_train, y_train):

            X_train_cv = X_train.iloc[indexes_train]
            y_train_cv = y_train[indexes_train]
            X_valid_cv = X_train.iloc[indexes_valid]
            y_valid_cv = y_train[indexes_valid]

            start_time = time.time()
            model = xgb.XGBClassifier(random_state=42, use_label_encoder=False)
            model.fit(
                scipy.sparse.csr_matrix(X_train_cv),
                y_train_cv,
                early_stopping_rounds=30,
                eval_set=[(scipy.sparse.csr_matrix(X_valid_cv), y_valid_cv)],
                eval_metric='mlogloss',
                verbose=0
            )
            end_time = time.time()

            score = model.score(scipy.sparse.csr_matrix(X_test), y_test)
            print('Samples: {}, New 0 Col: {}, [{}/{}], Score: {:.0f}%, {:.0f}s'.format(
                n_samples, n_new_0_col, loop, n_splits, score * 100,
                end_time - start_time
            ))

            cv_results.append([n_samples, n_new_0_col, loop, end_time - start_time, score])
            loop += 1

cv_results = pd.DataFrame(
    cv_results, 
    columns=['n_samples', 'new_n_0_col', 'loop', 'elapsed_time', 'score']
)
cv_results.to_csv(result_dir_path.joinpath('model_result_non_sparse.csv'), index=False)
cv_results

f:id:dskomei:20210416105115p:plain:w500


 上のコードにおいて、スパースなデータを使った実験と変わったところは、「csr_matrix」関数を使いデータを非スパース化してから、XGBoostを学習させていることです。筆者の環境では、ベースデータ20,000、追加する0列のデータ10,000での学習時間は、74秒でした。つまり、非スパース化することで9倍も短縮されています。


実験結果をグラフ化


 上記の実験で、データを非スパース化することで、XGBoostの学習時間の短縮を確認できました。このことが視覚的に裏付けるために、グラフ化して見てみましょう。

target_data = pd.concat([
    pd.read_csv(result_dir_path.joinpath('model_result_non_sparse.csv')).assign(
        category='non_sparse'
    ),
    pd.read_csv(result_dir_path.joinpath('model_result_sparse.csv')).assign(
        category='sparse'
    )
], axis=0)

plot_data = target_data.groupby(['category', 'new_n_0_col', 'n_samples']).agg({
    'elapsed_time': ['mean', 'std'], 'score': ['mean', 'std']
})
plot_data.columns = ['elapsed_time_mean', 'elapsed_time_std', 'score_mean', 'score_std']
plot_data.reset_index(inplace=True, drop=False)

g = sns.relplot(
    data=plot_data.assign(new_n_0_col=lambda x: x.new_n_0_col.astype(str)),
    x='new_n_0_col',
    y='elapsed_time_mean',
    hue='category',
    col='n_samples',
    kind='line',
    marker='o',
    markersize=10
)
g.fig.suptitle('スパース/非スパースデータによるXGBoostの学習時間', fontsize=18, y=0.98, weight='bold')
g.fig.set_figwidth(12)
g.fig.set_figheight(6)
for ax in g.axes.flat:
    ax.set_xlabel('値0の列追加件数', size=14)
    ax.set_title(ax.get_title(), size=14)
g.set_ylabels('')

leg = g._legend
leg.set_bbox_to_anchor([0.17, 0.8])

for i, ax in enumerate(g.axes.flat):
    for tick in ax.get_yticks()[1:-1]:
        ax.axhline(tick, alpha=0.1, color='grey')
        
plt.tight_layout()
plt.savefig(result_dir_path.joinpath('model_result_sparse_vs_non_sparse_slapsed_time.png'), dpi=300)


f:id:dskomei:20210416080705p:plain:w500


 上のグラフを見て分かる通り、スパースなデータは非スパースなデータに比べて、値0の列が増えるに連れ、学習時間が顕著に増加しています。スパースなままXGBoostを学習させることは、多大な時間を浪費するのです。


 非スパースにより学習時間が短くなることが証明されましたが、これにより予測精度が落ちていたら元も子もありません。そこで、予測精度が影響を受けているかを確認しておきます。

result = pd.pivot_table(
    data=plot_data[['n_samples', 'category', 'new_n_0_col', 'score_mean', 'score_std']],
    index=['n_samples', 'new_n_0_col'],
    columns='category'
)
result

f:id:dskomei:20210416105325p:plain:w400


 予測精度に変化はありません。つまり、スパースなデータのままXGBoostを学習させることは、損しかないと言えそうです。


終わりに


 今回はXGBoostというか機械学習における学習時間の高速化の話をしました。ワンライナーで学習時間が大幅に変わるので、やらない手はないと思います。今回取り上げませんでしたが、XGBoostのハイパーパラメータの調整や機械学習の精度向上に関しては、以下の本が参考になりました。



Pythonを使ってロジスティック回帰の限界効果を求める

 
私の座右の銘は「限界突破」でした。それは、自分が想像できる範囲の自分で決めた限界を乗り越えることに喜びを覚えていたわけです。まるで、判別境界を少しでも超えたらその瞬間に異なるクラスに振り分けられるように、設定した”限界”を超えた瞬間に突破と考えていたようです。


前置きが長くなりましたが、今回は”限界”という名が付くものの、全くそのニューアンスのない限界効果(Maginal Effect)について書いきます。限界効果とは、経済学でよく使われており、財を1単位追加して消費することによる効用の増加分のとことです(限界効用(Wikipedia))。線形回帰の話でよく聞くかと思います。限界効果がわかると、各変数が1単位増加したときに被説名変数がどれだけ増減するかがわかり施策を行いやすいです。


線形回帰の限界効果に関しては、巷に溢れているので今回は扱いません。今回はロジスティック回帰の限界効果に関して数式の導出とpythonを使っての実践の両面で書いていきます。線形回帰では、偏回帰係数が各変数の限界効果ですが、ロジスティック回帰では同じようにいきません。それは、ロジスティック回帰が非線形モデルだからです。ロジスティック回帰で限界効果を求めるためには、何かしらの処理が新たに必要です。今回のコードはこちらにあります。



ロジスティック回帰の限界効果の数式


ロジスティック回帰の限界効果は、モデルの偏回帰係数ではないです。なので、新たに計算をしなければいけません。そこで、限界効果が何だったかを思い出すと、変数が1単位増加したときに被説明変数の増減量のことでした。簡単な単回帰モデルで考えると、変数の傾きが限界効果に当たります。単回帰モデルを \(y(x)=ax+b\) とすると、下記の式変形で得られます。


\(\displaystyle y(x+1)=a(x+1)+b \)
\begin{align*}
y(x+1)-y(x) &= a(x+1)+b - (ax+b) \\
&= a
\end{align*}


これをロジスティック回帰で行いたいのですが、ロジスティック回帰は非線形モデルなので、傾きは各変数の値で変わってしまい、線形回帰モデルのように簡単には求まりません。ではどうするかというと、変数の値で傾きは変わってしまうのならば、傾きを平均すればええやんてな感じになります。 


f:id:dskomei:20200906171253p:plain:w600



関数の傾きは微分により求めることができます。そこで、ここからは数式によりロジスティック回帰の限界効果(平均限界効果)を求めていきます。データ \(i\) 番目の \(j\) 番目の変数を \(x_{ij}\) とし、被説名変数 \(Y_i\) が1となる確率を \(\mathrm Pr (Y_i=1)\) とすると、限界確率効果(Marginal Probability Effect)\(MPE_{ij}\) は次式となります。


\(\displaystyle MPE_{ij} = \frac { \partial \mathrm Pr (Y_i = 1)} { \partial x_{ij}} \)


ここでロジスティック回帰モデルにおいて、\( \mathrm Pr (Y_i = 1) \) は、 \( \mathrm Pr (Y_i = 1) = f( \mathrm X_i \mathrm{ \beta } ) = \frac {e^x} {1+e^x} \) となります。
変数の数を \(k\) とすると、\( \mathrm X_i = [x_{i1}, x_{i2}, ..., x_{ik}] \) であり、\( \mathrm X_i \) はデータ \( i \) の各変数の値のベクトルです。 \( \mathrm{\beta} \) はロジスティック回帰モデルの偏回帰係数のベクトルであり、 \( \mathrm{\beta = [\beta_1, \beta_2, ..., \beta_j ]} \) です。これらをもとに \( MPE_{ij} \) は次式となります。


\(\displaystyle MPE_{ij} = \frac { \partial f ( \mathrm X_i \mathrm{ \beta })} { \partial x_{ij}} \)


このときに注目されるのは、分子の \( X_i \) はベクトル変数であり、分母の \( x_{ij} \) はスカラー変数です。よって、このままでは偏微分できず、式変形を必要とします。


\(\displaystyle MPE_{ij} = \frac { \partial f ( \mathrm X_i \mathrm{ \beta })} { \partial \mathrm X_i \mathrm{ \beta }} \cdot \frac { \partial \mathrm X_i \mathrm{ \beta }} { \partial x_{ij} } \)


上式の右側の \( \frac { \partial X_i \beta} { \partial x_{ij} } \) は \( \beta_j\) となります。


\(\displaystyle \frac { \partial X_i \beta} { \partial x_{ij} } = \frac {\partial (x_{i1}\beta_1 + x_{i2}\beta_2 + ... +x_{ij}\beta_j) } { \partial x_{ij}} = \beta_j \)


\( \frac { \partial f ( \mathrm X_i \mathrm{ \beta })} { \partial \mathrm X_i \mathrm{ \beta }} \) はロジスティック回帰の偏微分です。


\( \displaystyle f(x) = \frac { e^x } { 1 + e^x } \)
\begin{align*}
f'(x) &= { e^x }' \cdot (1 + e^x )^{-1} + e^x \cdot ( (1 + e^x )^{-1} )' \\
&= e^x \cdot (1 + e^x )^{-1} + e^x \cdot ( -(1 + e^x )^{-2} )e^x \\
&= \frac { e^x } { 1 + e^x } - \frac { e^{2x} } {(1+e^x)^2} \\
&= \frac { e^x } { (1 + e^x)^2 } \\
&= \frac {e^x} {1+e^x} \cdot (1 - \frac { e^x } { 1+e^x }) \\
&= f(x) \cdot ( 1 - f(x) )
\end{align*}


上式を見てもらえば分かる通り、ロジスティック回帰の偏微分の結果は、ロジスティック回帰 \(f(x)\) で表すことができ、それはつまり、ロジスティック回帰モデルの推測値だけを使って計算できるというとても便利なわけです。これまでの導出結果を踏まえて、限界確率効果 \(MPE_{ij}\) は以下で求められます。


\( \displaystyle MPE_{ij} = f( \mathrm X_i \mathrm{\beta} )(1-f( \mathrm X_i \mathrm{\beta} )) \beta_j \)


今回の本題である限界効果は、限界確率効果の平均をとることで求まります。これは、変数の値で傾きが変わるのだから、平均をとってしまえばよいということです。この方法により求まる限界効果は、平均限界効果(Average Marginal Probability Effect)と呼ばれます。


\( \displaystyle AMPE_j = \frac {1} {n} \sum_{i}^{n} f( \mathrm X_i \mathrm{\beta} )(1-f( \mathrm X_i \mathrm{\beta} )) \beta_j \)


ロジスティック回帰の限界効果(平均限界効果)を求める数式がわかりました。結果を見ると、大層おおがかりなものではなく、ロジスティック回帰の予測値と係数だけを使う非常にシンプルなものとなりました。せっかくなので、実際にデータを使って求めてみます。


実際にロジスティック回帰の限界効果を求める


これまでの話で、ロジスティック回帰における限界効果(平均限界効果)を求めるための数式がわかりました。なので、その数式通り愚直に計算をすればよいわけです。それでは、実践します。


実践のための準備


必要なモジュールをインポートし、結果を保存するためのディレクトリを作成しています。今回は、scikit-learnのロジスティック回帰を使います。

from pathlib import Path
import pandas as pd
import numpy as np
import seaborn as sns
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import scipy.stats as ss

result_dir_path = Path('result')
if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)


使用データの準備


今回使用するデータは乳がんの診断データです。分類目的で使用されるデータであり、各ケースにおいて良性腫瘍か悪性腫瘍かの診断結果がついています。レコード数は569、カラム数は30です。

cancer = load_breast_cancer()
cancer_data = pd.DataFrame(cancer.data, columns=cancer.feature_names)
cancer_data['y'] = cancer.target

X_data = cancer_data.drop(columns='y')
y_data = cancer_data['y'].tolist()

X_train, X_test, y_train, y_test = train_test_split(
    X_data,
    y_data,
    test_size=0.3,
    random_state=42
)



ロジスティック回帰モデルの構築


乳がん診断データを分類するロジスティック回帰モデルを構築します。ここらへんは流れ作業ですね。

model = LogisticRegression(max_iter=10000)
model.fit(X_train, y_train)

print('正答率:{:.0f}%'.format(
    model.score(X_test, y_test)* 100
))


正答率は下記の図の通り、テストデータにおいて98%です。まぁまぁですね。
f:id:dskomei:20200906111443p:plain:w600



モデルの係数の確認


上記でモデルを構築することができたので、すかさず限界効果を求めようとなりますが、各係数の状況を先に把握しておきます。
モデルの係数の信頼性を確認するためにp値を見たいのですが、scikit-learnのロジスティック回帰ではp値を求める関数はないです。そこで、下記の方のコードを参考にさせてもらいました。
P values for sklearn logistic regression · GitHub

def calc_logistic_p_value(model, X, y, alpha=0.05):
    
    denom = (2.0 * (1.0 + np.cosh(model.decision_function(X))))
    denom = np.tile(denom,(X.shape[1],1)).T
    F_ij = np.dot((X/denom).T,X) ## Fisher Information Matrix
    Cramer_Rao = np.linalg.inv(F_ij) ## Inverse Information Matrix
    sigma_estimates = np.sqrt(np.diagonal(Cramer_Rao))
    
    coefs = model.coef_[0]
    n = len(X)
    z_scores = coefs/sigma_estimates # z-score for eaach model coefficient
    p_values = [ss.norm.sf(abs(x))*2 for x in z_scores] ### two tailed test for p-values
    
    z_ = ss.norm.ppf(1-alpha/2)

    uppers = coefs + z_ * sigma_estimates
    lowers = coefs - z_ * sigma_estimates
    
    return pd.DataFrame({
        'colname' : X.columns.tolist(),
        'coef' : coefs,
        'sigma' : sigma_estimates,
        'z_score' : z_scores,
        'p_values' : p_values,
        'lower_{:.3f}'.format(alpha/2)  : lowers,
        'upper_{:.3f}'.format(1-alpha/2) : uppers,
        'significant' : ['*' if x <= alpha else '' for x in p_values]
    })


result = calc_logistic_p_value(
    model=model,
    X=X_train,
    y=y_train,
    alpha=0.05
)
result.to_csv(result_dir_path.joinpath('logit_summary.csv'), index=False)


下記の図は、学習済みのロジスティック回帰モデルの係数とp値です。p値が有意水準5%を下回った変数は「worst texture」だけです。「worst texture」の値が乳がんの診断に影響していることがわかります。「worst texture」の係数はマイナスになっており、値が小さくなると悪性腫瘍と診断される確率が上がるようです。どの変数が影響するかはわかりましたが、モデルの係数は限界効果ではないため、悪性腫瘍への診断への効果がいまいちつかめません。それでは、今回のメインテーマであるロジスティック回帰の平均限界効果を実際に求めてみます。


f:id:dskomei:20200906154517p:plain:w600


ロジスティック回帰の限界効果(平均限界効果)


ここまででロジスティック回帰のモデルを構築し、どの変数が被説明変数に影響しているかをモデルの係数により把握できましたが、どれだけ被説明変数に影響するかはわかりませんでした。なので、上記で導出した数式を使い、平均限界効果を求めます。実際のコードは大したことをしておらず、シンプルに書けます。

def calc_marginal_probability_effect(model, X, alpha=0.05):
    
    predict_values = model.predict_proba(X)[:, 1]
    predict_values = predict_values * (1 - predict_values)
    
    mpe = np.tile(predict_values.reshape((-1, 1)), X.shape[1]) * model.coef_[0]
    
    n = len(X)
    ampes = mpe.mean(axis=0)
    stds = mpe.std(axis=0)
    
    t_values = ampes / (stds/np.sqrt(n-1))
    p_values = [1 - ss.t.cdf(x, df=n-1) if x > 0 else ss.t.cdf(x, df=n-1) for x in t_values]
    
    t_ = ss.t.ppf(1-alpha/2, df=n-1)

    uppers = ampes + t_ * (stds/np.sqrt(n-1))
    lowers = ampes - t_ * (stds/np.sqrt(n-1))
    
    return pd.DataFrame({
        'colname' : X_data.columns.tolist(),
        'coef' : model.coef_[0],
        'ampe' : ampes,
        'mpe_std' : stds,
        't_value' : t_values,
        'p_value' : p_values,
        'lower_{:.3f}'.format(alpha/2)  : lowers,
        'upper_{:.3f}'.format(1-alpha/2) : uppers,
        'significant' : ['*' if x <= alpha else '' for x in p_values]
    })

result = calc_marginal_probability_effect(
    model=model,
    X=X_train,
    alpha=0.05
)
result = result.sort_values('ampe')
result.to_csv(result_dir_path.joinpath('logit_ampe.csv'), index=False)


下記の図では、平均限界効果の計算結果を示しています。先程、ロジスティック回帰モデルの係数で優位なのは「worst texture」であることがわかりました。「worst texture」の平均限界効果を見てみると、-1.9%です。つまり、「worst texture」がわずかに上がると悪性腫瘍と診断される確率が1.9%減少することがわかりました。


f:id:dskomei:20200906162152p:plain


グラフにより確認


実際に「worst texture」の値が被説明変数に影響がありそうかを、グラフにより確認してみます。確かに「worst texture」は、良性と悪性で差があるように見えます。ただ他の変数も同じような傾向がある気もします。こちらは今度詳しく見てみたいです。


f:id:dskomei:20200906164146p:plain:w700


終わりに


最近は因果推定に興味があり、傾向スコアでよく使うロジスティック回帰に関して、勉強がてら今回のテーマを書いてみようと思いました。因果推定を勉強しているので計量経済学の本をいくつか読んでいます。その中でも下記の本は、実際の分析結果の具体例があり理解しやすかったです。




参考WEB


XGBoostのアルゴリズムを論文を読んで解説

 
夕焼けと紅葉が同化するような季節になると、毎日の服選びに時間がかかるように、ほんの少し昔に遡ると、機械学習のアルゴリズムを何にするかは迷いの種でした。ところが、今や機械学習のご意見場的な立ち位置になったXGBoostが現れてかららは、XGBoostをとりあえず使ってみるというのが定番です。なので、XGBoostにすごいお世話になってるわけですが、中身をなんとなくしか知らいままずっと使っていました。そこで、XGBoostの論文を読んでアルゴリズムの解説を行おうと思います。



ザクッと理解する


大仏に近づきすぎると膝小僧しか見えず、全体像がわからいために、目にしているのは大仏なのかもわからないかもしれません。つまり、物事を理解するためには、いきなり細部を深くほるというよりは、全体像を把握してから細部の話をすることが大事であり、全体の中でどの部分に関しての話をしているのかを意識することで理解度が上がります。


前置きはここまでにして、早速中身のアルゴリズムの話に入っていきます。すべてのアルゴリズムには型があるように、XGBoostにも核となるアルゴリズムの型があります。それがBoostingです。


Boosting


Boostingは、複数の学習器をまとめて一つのモデルとするアンサンブル学習の一つです。一つ一つは高い精度が出ない弱い学習器であっても、誤差を改善するように弱い学習器を追加していき、それらをまとめて一つのモデルにすることで精度を上げる手法です。


間違いに着目し、それを改善するようにモデルをどんどん追加していくので、誤差が徐々に小さくなっていき、追加されるモデルのウエイトも小さくなっていきます。アンサンブル学習にはこの他にも、個々の学習器を並列して学習させ、予測時に合わせるBaggingという手法もあります。Baggingの代表的なものがRandom Forestです。


武将の戦法で例えると、Boostingは侍が直列になって進み、前の人が倒れたらその倒れた人の後ろの人が次に戦い、Baggingは並列になって進み、全員が最前線になって戦うという感じでしょうか。


Gradient Boosting


Gradient Boostingは、Boostingにおいて、勾配情報を使って損失を減少させる方向を決める方法です。


XGBoostのアルゴリズム


XGBoostは、学習器として回帰木を使い、Boostingによってモデルの構築と学習を行い、学習時にはモデルの勾配情報でパラメータを更新するモデルです。ざくっとしたアルゴリズムは以下の流れになります。


① 回帰木を学習させる
② ①の予測値と目的地から計算される目的関数を改善するように、新たな回帰木を学習させ、モデルに追加する
③ ②を指定した回帰木の本数の分だけ繰り返す
④ モデルの予測値はデータが各回帰木に属する葉の重みの合計値


②について補足すると、追加される2本目以降の木は、目的変数とそれまでに作成した回帰木の予測値との誤差に対して学習が行われます。そのため、木を追加するたびにモデルの予測値が目的値に合致していきます。ただこれだと、深い木を作ったときに過学習してしまうかもしれません。そこで、モデルを学習する際に正則化項を追加し、パラメータが過大にならないように抑制しています。


数式で理解する


上記では、XGBoostのアルゴリズムの概形を触った感じだったので、数式を見ながらより深く理解していきたいと思います。


モデルの予測値


上記で触れたようにXGBoostでは、回帰木をどんどん追加してモデルを構築します。そのため、これからの説明においてモデルの中のどの回帰木を指しているかがわかるように、モデルの回帰木を追加した順番を\(k\)で表します。


最初に、回帰木を1つ学習します。そして、回帰木を追加して学習するということを繰り返し、モデル全体の精度を高めていきます。\(k\)番目の木の学習においては、\(k-1\)番目の木までの予測を補正するように行います。回帰木が順々に追加されてモデルが完成すると、その完成したモデルを使って予測したくなりますよね。そのモデルの予測値とは、データを各回帰木で分類したときに属する、各回帰木の葉の重みの合計値です。つまり、各回帰木の葉の重みが大事なのです。この重みをどのように求めるかは、後ほど出てきますので、詳細はしばしお待ち下さい。


それでは、モデルの予測値を数式で書いてみます。データ数が\(n\)、特徴量が\(m\)個のデータ群を\(D={(\textrm x_i, y_i)}\)とし、\(K\)個の回帰木で表すと、予測式は以下のようになります。と、型苦しく書いていますが、そんなに難しい式ではありません。要は、予測値は各回帰木を合計した値ですよといっているだけです。


\(\displaystyle \hat y_i=\phi (\mathrm x_i)=\sum_{k=i}^K f_k(\mathrm x_i), f_k \in \mathcal F\)
\(\displaystyle \mathrm where\ \mathcal F={f(\mathrm x)=w_{q(\mathrm x)}}\)
\(\displaystyle q:\mathbb R^m \rightarrow T, w \in \mathbb R^T\)


上記の式に出てきた各変数の説明は以下になります。何回も言いますが、大事なのは葉の重みです。この値によってデータの将来(分類先)が変わってしまうのですから。その説明まで近づいておりますが、もう少しのご辛抱を。

  • \(\mathcal F\):回帰木の集合
  • \(f_k(\textrm x_i)\):データ\(\textrm x_i\)の重み
  • \(\hat y_i\):予測値(それぞれの回帰木の重みの合計)
  • \(w\):葉の重み
  • \(T\):歯の数



損失関数


上記の説明でモデルの予測式が出来上がったので、次に行うのはモデルの予測値と目的値があっているかの確認ですね。この一手間が大事で、常にテストの点数が満点の出来杉君みたいな人だとテストは解くだけでよく、答え合わせをしなくてもよいですよね。しかし、常に満点というのはありえず、次のテストで良い点を取るために、間違っているところをしっかり理解して、再度間違うことを防ぎますよね。機械学習でも同じように振り返りを行い、次に間違わないように正していきます。間違いをスコア化した損失関数を作り、そのスコアが小さくなるようにモデルのパラメータを修正します。これにより、予測モデルが賢くなっていきます。


XGBoostの損失関数は、予測値と目的値の誤差の合計に正則化項を足したものになります。これを式で表すと、損失関数\(\mathcal L(\phi)\)は以下の通りになります。


\(\displaystyle \mathcal L(\phi)=\sum_i l(\hat y_i,\ y_i)+\sum_k \Omega(f_k)\)
\(\displaystyle \mathrm where\ \Omega(f_k)=\gamma T+\frac 12\lambda\|w_k\|^2\)


上の2つ目の式は正則化項を表します。この式の肝は回帰木の重み\(w\)が入っていることです。これがあることで、損失関数の最小化時に回帰木の重みが抑制され、過学習になることを防ぎます。


各変数の説明は以下のとおりです。

  • \(l\):\(y_i\)と\(\hat y_i\)の差分を測るための微分可能な損失関数
  • \(\Omega\):正則化項
  • \(\gamma\), \(\lambda\):パラメータ



損失関数の最適化 〜Gradient Tree Boosting〜


損失関数が出来上がったので、あとは損失関数を最適化するだけです。


データ\(i\)が与えられ、\(t\)番目の回帰木\(f_t\)を最適化するとします。そのために、上記で作った損失関数を変形させます。\(t\)番目までの回帰木の予測値を\(t-1\)番目までの回帰木の予測値\(\hat y_i^{(t-1)}\)と\(t\)番目の回帰木の重み\(f_t\)を足した式で表し、損失関数を変形させます。


\(\displaystyle \mathcal L^{(t)}=\sum_i^n l(y_i,\ \hat y_i^{(t-1)}+f_t({\mathrm x_i}))+\Omega(f_t)\)


上記の式の\(f_t\)をTaylor展開の2階項まで求めます。この式を使うことで、最適化する際の収束が早くなります*1。その結果は以下のとおりです。


\(\displaystyle \mathcal L^{(t)} \simeq \sum_i^n [l(y_i,\ \hat y_i^{(t-1)})+g_if_t({\rm x_i})+\frac 1 2 h_if_i^2({\rm x_i})]+\Omega(f_t)\)


このとき、\(g_i=\partial _{\hat y^{(t-1)}}\ l(y_i,\hat y_i^{(t-1)})\), \(h_i=\partial _{\hat y^{(t-1)}}^2\ l(y_i,\hat y_i^{(t-1)})\)とします。更に、定数項は最小化に関係がないので、上記の式から省きます。そして、諸々を整理した下の式を最小化します。


\(\displaystyle \hat {\mathcal L}^{(t)} = \sum_i^n [g_i f_t({\mathrm x_i})+\frac 1 2 h_if_i^2({\mathrm x_i})]+\Omega(f_t)\)


ここで、上記の式を\(\Omega\)を展開します。\(\Omega\)は正則化項を表しており、葉の重みの二乗項と葉の数を足したものです。葉の重みが出てきたので、これまでのデータ単位の式を葉単位に変形します。回帰木において、データ\(\textrm x_i\)が属する葉の番号を\(j\)と表して、葉\(j\)に属するデータの集合を\(I_j\)とします。


\(\displaystyle \hat {\mathcal L}^{(t)} = \sum_i^n [g_i f_t({\mathrm x_i})+\frac 1 2 h_if_t^2({\mathrm x_i})]+\gamma T+\frac 1 2 \lambda \sum_{j=1}^T w_j^2\)
\(\displaystyle = \sum_{j=1}^T [(\sum_{i \in I_j} g_i)w_j + \frac 1 2 (\sum_{i \in I_j} h_i + \lambda ) w_j^2] + \gamma T\)


上記の式では、\(\Omega\)を展開したあとに、\(f_t(\textrm x_i)\)を葉の重みに変形しています。そのため、回帰木の葉を1からTまで順々に見ていく大かっこと、その大かっこの中で各葉に分類されたデータに関わる項(\(g_i, h_i\))にその葉の重み\(w_j\)をかけています。これで葉の重みが出てきて、損失関数の変形は終了です。この損失関数を最小化する重みを見つけられば、最適なXGBoostを構築することができます。


上記の式を最小化する葉の重み\(w_j\)を求めるために、上記の式を葉の重み\(w_j\)で微分します。


\(\displaystyle {\hat {\mathcal L}^{(t)}}_j' = \sum_{i \in I_j} g_i + (\sum_{i \in I_j} h_i + \lambda ) w_j \)


すごいすっきりとした式になりましたね。この式を0としたときの葉の重み\(w_j\)がまさしく、近似した損失関数を最小化します。つまり、誰しも追い求める大秘宝にたどり着いたわけです。


\(\displaystyle w_j^*=-\frac {\sum_{i \in I_j} g_i} {\sum_{i \in I_j} h_i + \lambda}\)


損失関数を最小化する\(w_j^*\)が求まったので、これを近似した損失関数に代入すると最小の損失値が求まります。ここで、XGBoostの構造を\(q\)としています。


\(\displaystyle \hat {\mathcal L}^{(t)}(q) = - \frac 1 2 \sum_{j=1}^T \frac {(\sum_{i \in I_j})^2} {\sum_{i \in I_j} h_i + \lambda} + \gamma T\)


上記の式がXGBoostを評価する式になります。この式から求まる値を最小にする回帰木の分岐式の組み合わせを求めることで、最強なXGBoostを作ることができます。つまり、XGBoostってどうやって作るのかと尋ねられると、上記の式を最小にする分岐式の組み合わせを求めればいいんだよと答えればよいわけですね。


しかしながら、上記の式を最小化する分岐式の組み合わせを求めるのは、木の深さ・木の数が増えると現実的な時間では困難になります。そこで、現実的な時間で損失関数をそこそこ小さくする解を求めることを考えます。そのために、ヒューリスティックな貪欲的アルゴリズムによって分岐を行います。これは、目の前の最良な手を選択することで、全体としてそこそこ良い手の組み合わせになるという考え方です。XGBoostにおいては、最初の回帰木の葉一つから以下の式を最小にする分岐を選択していくことを繰り返して、全体の回帰木の組み合わせを構築します。


\(\displaystyle \hat {\mathcal L}_{split} = \frac 1 2 \Biggl[ \frac {(\sum_{i \in I_L} g_i)^2} {\sum_{i \in I_L} h_i + \lambda } + \frac {(\sum_{i \in I_R} g_i)^2} {\sum_{i \in I_R} h_i + \lambda } - \frac {(\sum_{i \in I} g_i)^2} {\sum_{i \in I} h_i + \lambda } \Biggr] - \gamma \)


上記の式では、葉\(I\)に属するデータの集合を分岐したとき、左の葉に属するようになったデータの集合を\(I_L\)、右の葉に属するようになったデータの集合を\(I_R\)としています。つまり、\(I=I_L \cup I_R\)です。分岐式の候補の中でこの式を最小化する分岐の候補を選択しては指定の深さになるまで分岐をするを繰り返します。


参考


以下の本はKaggleで使用されている手法の解説本ですが、XGBoostに関してもわかりやすく記載してありました。


Pythonでトピックモデル Word Cloud と LDA

 
SNSがコミュニケーションのインフラになりつつあることで、世の中は言葉で溢れています。この膨大な言葉の文章をまとめることで一つ一つの文章からはわからない傾向を新たに獲得することができます。具体的には、文章をカテゴライズして分類することで、どのカテゴリが人気なのかがわかったりします。これは機械学習の分類問題としてよく扱われていますが、重要な前提として「各文章は一つのカテゴリに属す」としています。しかしながら、いくつかのトピックが含まれている文章は多々あります。ファミレスでよく聞く井戸端談義はトピックだらけです。そこで、一つのカテゴリに分類するのではなく、分類に重要な単語(トピック)の重み付けで分類するようにしたのが、トピックモデルです。


今回は、文章の傾向を出現頻度トピック抽出により理解していきます。そのために、単語の出現頻度をインパクトのある可視化をするWord Cloudと各文章をトピック分類するLatent Dirichlet Allocation(以下、LDA)を使います。以下は今回行ったことの結果です。左の図は学問のすすめのWord Cloudを作成したものであり、右はアニメを含んだtweetのLDAによるトピックを求めた結果です。今回使用したコードはgithubに置いております。


f:id:dskomei:20180409233434p:plain:w250 f:id:dskomei:20180410224212p:plain:w250


このような言語関連の分析をする上で扱うデータを揃えるのは大変です。言語データを入手するために、あれやこれやと試行錯誤をしないといけないため、本来やりたい分析になかなか入れなかったりします。言語データを整理する作業にはあまり時間をかけたくないのが本音です。しかし、言語データの分析は前処理次第で結果が大きく変わるため、前処理を雑にやると良い分析ができません。フライドチキンの下味と一緒です。なので、今回は、言語データを集めて前処理をする部分に関しても触れていきたいと思います。



言語データを簡単に取得


トピックモデルを早速試していきたいのですが、言語データがないと何もできません。そこでまず、簡単に入手可能な言語データを使って一通り試してみます。簡単に入手可能な言語データとして、青空文庫のオンラインの書き起こしデータがあります。ここでは青空文庫の本が電子化されており、自由にダウンロードすることができます。今回は昔読んで感銘を受けた福沢諭吉さんの「学問のすすめ」をスクレイピングして、分析に使いたいと思います。


福沢諭吉さんの学問のすすめをスクレイピングして入手するにあたって、pyqueryライブラリを使用しています。スクレイピングのライブラリとしてはBeautifulsoupが有名ですが、pyqueryも使いやすいです。pyqueryの使い方はこちらがわかりやすかったです。青空文庫では、メインの内容はdivタグの『main_text』内に入っています。指定したurlからhtmlのテキストを取得し、学問のすすめの内容が記載されている部分を抜き取り、文ごとに分けて保存しています。この一連の処理は以下のとおりです。

from pathlib import Path
import urllib.request
from pyquery import PyQuery as pq

"""
青空文庫から「学問のすすめ」の文章を取得する

"""

data_dir_path = Path('./data')

url = 'https://www.aozora.gr.jp/cards/000296/files/47061_29420.html'
with urllib.request.urlopen(url) as response:
    html = response.read()
query = pq(html,  parser='html')

text = query(".main_text").text().replace('\n', '')
texts = text.split('。')
texts = [text_+'\n' for text_ in texts]

with open(data_dir_path.joinpath('gakumon_no_susume.txt'), 'w', encoding='utf-8') as file:
    file.writelines(texts)



文の要素の抽出 形態素解析


それでは抽出した文をいくつか見てみましょう。

初編「天は人の上に人を造らず人の下に人を造らず」と言えり
されば天より人を生ずるには、万人は万人みな同じ位にして、生まれながら貴賤(きせん)上下の差別なく、万物の霊たる身と心との働きをもって天地の間にあるよろずの物を資(と)り、もって衣食住の用を達し、自由自在、互いに人の妨げをなさずしておのおの安楽にこの世を渡らしめ給うの趣意なり
されども今、広くこの人間世界を見渡すに、かしこき人あり、おろかなる人あり、貧しきもあり、富めるもあり、貴人もあり、下人もありて、その有様雲と泥(どろ)との相違あるに似たるはなんぞや


やはり格式高い文章で自分とは住む世界が違うのではないかと思ってしまいます。それはさておき、それぞれの文を何らかのカテゴリに分けることを考えるのですが、文をそのままいくつか見てもどのようなカテゴリに分類をすればいいのかなかなかわかりません。そこで、文に含まれている単語の頻度を利用してカテゴリに分けることを考えてみます。つまり、よく出現する単語は重要であろうということです。単語の出現頻度を求めたいので、まず文を単語に分けなければいけません。更に、単語に分けカテゴリ分類する際には情報として無駄な助詞とか記号などは消し去りたいです。また動詞の場合、活用形によって形が変わってしまうので、同じ意味のものは統一した一つの単語として扱いたいです。このように、言語データを分析する上では前処理が欠かせません。この処理を一から書くとなると骨が折れますが、モジュール「janome」を使うことで、簡単に上記のことができます。言語データの前処理に関して下記にまとめます。

  1. 文から不要な文字を削除
  2. 文を単語ごとに分割
  3. 単語を正規化し、必要な単語のみを取得
  4. 単語の原型に変換


下図は上記の1~4を学問のすすめに対して行った結果のうちのはじめの一文を載せています。


f:id:dskomei:20180409230108p:plain:w400


図を見て分かる通り、学問のすすめの最初の一文に対して、単語に分割し、助詞を削除し、単語を原型に変換できていることがわかります。この処理に関するコードを以下に記載します。

from pathlib import Path
from janome.charfilter import *
from janome.analyzer import Analyzer
from janome.tokenizer import Tokenizer
from janome.tokenfilter import *
from gensim import corpora


data_dir_path = Path('./data')
corpus_dir_path = Path('./corpus')


file_name = 'gakumon_no_susume.txt'

with open(data_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    texts = file.readlines()
texts = [text_.replace('\n', '') for text_ in texts]


# janomeのAnalyzerを使うことで、文の分割と単語の正規化をまとめて行うことができる
# 文に対する処理のまとめ
char_filters = [UnicodeNormalizeCharFilter(),         # UnicodeをNFKC(デフォルト)で正規化
                RegexReplaceCharFilter('\(', ''),     # (を削除
                RegexReplaceCharFilter('\)', '')      # )を削除
                ]

# 単語に分割
tokenizer = Tokenizer()


#
# 名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
#
class NumericReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if (parts[0] == '名詞' and parts[1] == '数'):
                token.surface = '0'
                token.base_form = '0'
                token.reading = 'ゼロ'
                token.phonetic = 'ゼロ'
            yield token


#
#  ひらがな・カタガナ・英数字の一文字しか無い単語は削除
#
class OneCharacterReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            # 上記のルールの一文字制限で引っかかった場合、その単語を無視
            if re.match('^[あ-んア-ンa-zA-Z0-9ー]$', token.surface):
                continue

            yield token


# 単語に対する処理のまとめ
token_filters = [NumericReplaceFilter(),                         # 名詞中の漢数字を含む数字を0に置換
                 CompoundNounFilter(),                           # 名詞が連続する場合は複合名詞にする
                 POSKeepFilter(['名詞', '動詞', '形容詞', '副詞']),# 名詞・動詞・形容詞・副詞のみを取得する
                 LowerCaseFilter(),                              # 英字は小文字にする
                 OneCharacterReplaceFilter()                     # 一文字しか無いひらがなとカタガナと英数字は削除
                 ]

analyzer = Analyzer(char_filters, tokenizer, token_filters)


tokens_list = []
raw_texts = []
for text in texts:
    # 文を分割し、単語をそれぞれ正規化する
    text_ = [token.base_form for token in analyzer.analyze(text)]
    if len(text_) > 0:
        tokens_list.append([token.base_form for token in analyzer.analyze(text)])
        raw_texts.append(text)

# 正規化された際に一文字もない文の削除後の元テキストデータ
raw_texts = [text_+'\n' for text_ in raw_texts]
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'w', encoding='utf-8') as file:
    file.writelines(raw_texts)

# 単語リストの作成
words = []
for text in tokens_list:
    words.extend([word+'\n' for word in text if word != ''])
with open(corpus_dir_path.joinpath(file_name.replace('.txt', '_word_list.txt')), 'w', encoding='utf-8') as file:
    file.writelines(words)



word cloud 単語の出現の頻度の可視化


文をうまいこと単語に分けることができたので、トピックを抽出していきたいのですが、その前にこのドキュメントにはどのような単語が多いのかの全体感をまず知りたいですよね。本当に言いたいことは何だったのかを。そこで、単語の出現頻度の可視化をまずしてみます。ただ、単純に出現頻度が多い順に単語を並べても芸がないので、可視化としてインパクトのある「Word Cloud」を使います。これは単語の出現頻度に応じて単語のサイズを大きくし、場所と角度をランダムにして表示することで、どの単語がよく使われているかをインパクトのある絵で確認できます。Word Cloudはamuellerさんがpythonでライブラリを作ってくださっています。インストールなどに関してはこちらが参考になります。それでは先ほど作成した学問のすすめの単語リストから作成したWord Cloudを見てみましょう。


f:id:dskomei:20180409233434p:plain:w350


学問のすすめでは有名な一文がありますが、Word Cloudを見ると、目立つのは「政府」、「知る」、「独立」といった単語です。昔読んだ記憶では、学問に励むことで自立することの大切さを唱えているのが主旨だったと記憶しているので、その点を表せられているのではないかと思います。それでは、このコードに関して以下に記載します。

from pathlib import Path
from wordcloud import WordCloud
import matplotlib.pyplot as plt


image_dir_path = Path('./image')
corpus_dir_path = Path('./corpus')

if not image_dir_path.exists():
    image_dir_path.mkdir(parents=True)

file_name = 'gakumon_no_susume_word_list.txt'

with open(corpus_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    text = file.readlines()
text = ' '.join(text).replace('\n', '')


# 日本語が使えるように日本語フォントの設定
fpath = 'C:\Windows\Fonts\ipaexg.ttf'


# ストップワードの設定
# ここで設定した単語はWord Cloudに表示されない
stop_words = [u'てる', u'いる', u'なる', u'れる', u'する', u'ある', u'こと', u'これ', u'さん', u'して', \
              u'くれる', u'やる', u'くださる', u'そう', u'せる', u'した', u'思う', \
              u'それ', u'ここ', u'ちゃん', u'くん', u'', u'て', u'に', u'を', u'は', u'の', u'が', u'と', u'た', u'し', u'で', \
              u'ない', u'も', u'な', u'い', u'か', u'ので', u'よう', u'', u'もの', u'もつ']

wordcloud = WordCloud(background_color="white",
                      font_path=fpath,
                      width=900,
                      height=500,
                      stopwords=set(stop_words)).generate(text)

plt.figure(figsize=(10, 8))
plt.imshow(wordcloud)
plt.axis("off")
plt.tight_layout()
plt.savefig(image_dir_path.joinpath(file_name.replace('list.txt', 'cloud.png')).__str__())
plt.show()



言語データの交代


先程、Word Cloudを作って学問のすすめの全体感を見たので、トピックモデルを使ってトピック分類をしていこうという流れになりますが、せっかくなのでデータを替えてみたいと思います。引き伸ばしているわけではありません汗)


様々な人の趣味趣向に関する言語データを使ってみてみたいと思います。そこで、個人的に興味がある「最近のアニメに関して」というテーマで言語データを集めてみました。とは言っても、twitterからアニメと言う単語を含んだツイートを1万件抽出しただけです。twitterからのデータの取得に関しては、今回は記載しませんが、こちらに詳しく書かれています。アニメツイートデータに対して学問のすすめのときに行ったような前処理をし、単語リストを作ると共に、トピックモデルによる分析のための文章のベクトル化も行っています。ここから単語リストを落とせます。文章のベクトル化の具体例としては、次の図のように同じ単語には同じインデックスを付与していくことで数値ベクトルに変換しています。


f:id:dskomei:20180410232127p:plain:w400


このアニメツイートデータに対するWord Cloudは次の通りです。左の図は、学問のすすめのときにやった前処理に加えて、記号などを新たに削除した単語リストで作成したものです。動詞の大きさが目立つかと思います。そこで、名詞だけに絞った単語リストから作成したのが右の図です。若干動詞がまだ含まれていますが、先程よりは各ツイートの内容が想像できそうな気がします。ゲームとアニメがタイアップしているのか、放送を心待ちにしているのかなど。


f:id:dskomei:20180410232720p:plain:w250 f:id:dskomei:20180410232738p:plain:w250


ツイートアニメの前処理のコードは以下の通りです。学問のすすめの際と変更した点は、様々な記号を消すようにしたことと、LDA用にコーパスを作成したことです。

from pathlib import Path
from janome.charfilter import *
from janome.analyzer import Analyzer
from janome.tokenizer import Tokenizer
from janome.tokenfilter import *
from gensim import corpora
import re



data_dir_path = Path('./data')
corpus_dir_path = Path('./corpus')
if not corpus_dir_path.exists():
    corpus_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
with open(data_dir_path.joinpath(file_name), 'r', encoding='utf-8') as file:
    texts = file.readlines()

texts = [text.replace('\n', '') for text in texts]



# janomeのAnalyzerを使うことで、文の分割と単語の正規化をまとめて行うことができる
# 文に対する処理のまとめ
char_filters = [UnicodeNormalizeCharFilter(),                               # UnicodeをNFKC(デフォルト)で正規化
                RegexReplaceCharFilter('http[a-z!-/:-@[-`{-~]', ''),        # urlの削除
                RegexReplaceCharFilter('@[a-zA-Z]+', ''),                   # @ユーザ名の削除
                RegexReplaceCharFilter('[!-/:-@[-`{-~♪♫♣♂✨дд∴∀♡☺➡〃∩∧⊂⌒゚≪≫•°。、♥❤◝◜◉◉★☆✊≡ø彡「」『』○≦∇✿╹◡✌]', ''), # 記号の削除
                RegexReplaceCharFilter('\u200b', ''),                       # 空白の削除
                RegexReplaceCharFilter('アニメ', '')                         # 検索キーワードの削除
                ]

tokenizer = Tokenizer()


#
# 名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
#
class NumericReplaceFilter(TokenFilter):
    """
    名詞中の数(漢数字を含む)を全て0に置き換えるTokenFilterの実装
    """
    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if (parts[0] == '名詞' and parts[1] == '数'):
                token.surface = '0'
                token.base_form = '0'
                token.reading = 'ゼロ'
                token.phonetic = 'ゼロ'

            yield token

#
#  ひらがな・カタガナ・英数字の一文字しか無い単語は削除
#
class OneCharacterReplaceFilter(TokenFilter):

    def apply(self, tokens):
        for token in tokens:
            # 上記のルールの一文字制限で引っかかった場合、その単語を無視
            if re.match('^[あ-んア-ンa-zA-Z0-9ー]$', token.surface):
                continue

            if re.match('^w+$', token.surface):
                continue

            if re.match('^ー+$', token.surface):
                continue

            yield token


token_filters = [NumericReplaceFilter(),       # 名詞中の漢数字を含む数字を0に置換
                 CompoundNounFilter(),         # 名詞が連続する場合は複合名詞にする
                 POSKeepFilter(['名詞']),      # 名詞・動詞・形容詞・副詞のみを取得する
                 LowerCaseFilter(),            # 英字は小文字にする
                 OneCharacterReplaceFilter(),
                 ] 

analyzer = Analyzer(char_filters, tokenizer, token_filters)


tokens_list = []
raw_texts = []
for text in texts:
    text_ = [token.base_form for token in analyzer.analyze(text)]
    if len(text_) > 0:
        tokens_list.append([token.base_form for token in analyzer.analyze(text)])
        raw_texts.append(text)


# 正規化された際に一文字もない文の削除後の元テキストデータ
raw_texts = [text_+'\n' for text_ in raw_texts]
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'w', encoding='utf-8') as file:
    file.writelines(raw_texts)

# 単語リストの作成
words = []
for text in tokens_list:
    words.extend([word+'\n' for word in text if word != ''])
with open(corpus_dir_path.joinpath(file_name.replace('.txt', '_word_list.txt')), 'w', encoding='utf-8') as file:
    file.writelines(words)
    

#
# 単語のインデックス化
#
dictionary = corpora.Dictionary(tokens_list)
# 単語の出現回数が1以下か、文を通しての出現割合が6割を超えているものは削除
dictionary.filter_extremes(no_below=1, no_above=0.6)
dictionary.save_as_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())

#
# 文の数値ベクトル化
#
corpus = [dictionary.doc2bow(tokens) for tokens in tokens_list]
corpora.MmCorpus.serialize(corpus_dir_path.joinpath(file_name.replace('.txt','_corpus.mm')).__str__(), corpus)



潜在的ディリクレイ配分法 LDA


単語の出現度合いの全体感を俯瞰したことで、どのようなツイートがあるのかなんとなくわかってきたような気がします。それでは、各文書をトピック分類していきます。そのためには、潜在的ディリクレイ配分法を使いますが、潜在的ディリクレイ配分法はどういう原理なのかに関しては、数式がゴリゴリ出てくるので今回は割愛します。気になる方がぜひこちらをご覧ください。


それでは、先程作った名詞のみのツイートアニメデータのトピック分類をしてみます。LDA自体は一行でできてしまいます。下記のコードでは、トピックを50個にして学習しています。

from pathlib import Path
import gensim


"""
LDAのモデルを作る

"""

corpus_dir_path = Path('./corpus')
model_dir_path = Path('./model')
if not model_dir_path.exists():
    model_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
dictionary = gensim.corpora.Dictionary.load_from_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())
corpus = gensim.corpora.MmCorpus(corpus_dir_path.joinpath(file_name.replace('.txt', '_corpus.mm')).__str__())


# トピック数50のLDAを作る
model = gensim.models.LdaModel(corpus,
                               num_topics=50,
                               id2word=dictionary)

model.save(model_dir_path.joinpath(file_name.replace('.txt', '_lda.pkl')).__str__())


LDAにより分類した結果を見てみたいと思います。下記の例は、ある文とその文に対する推測のトピックの番号とその重みを示しています。そして、2つ目の文章は、1つ目の文章とのコサイン類似度が最も近いと測定されたものを示しています。両方共主旨は異なりますが、お願いしているという点では同じなのでしょうか・・・。

ダ・カーポが好きな人はフォローよろしくです!アニメはⅠ、Ⅲ(Ⅱは今後見る予定)見てます。ゲームはIIIプラスのみプレイしてます。
[(31, 0.45358875), (41, 0.27089253), (10, 0.096332), (44, 0.09555033)]
オススメのアニメあったら教えてくださいねっ♪ #【定期】
[(31, 0.34243003), (41, 0.33756995)]


今回は50個のトピックにして分類しましたが、そのうちの10個を見てみたいと思います。行が各トピックを表しており、列が各トピックの中での重要な単語です。左の列ほど重要度が増します。トピック1は女性のキャラに関して述べている文章が集まっているのではないかと推測されます。


f:id:dskomei:20180410224212p:plain:w500


先程は最も類似度が高い文章を抽出しましたが、それができたのはトピックの重みをベクトル化することで、文章をベクトル化できたからです。そして、それは新しい文章に関してもできます。トピックモデルを使うことで学習に使っていない文章の分類も可能です。
 

LDAを使った分析の一連の流れは以下の通りです。

from pathlib import Path
import gensim
import numpy as np
from scipy.spatial import distance
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd


"""
LDAモデルを使ってトピック分類をする

"""


corpus_dir_path = Path('./corpus')
model_dir_path = Path('./model')
data_dir_path = Path('./data')
result_dir_path = Path('./result')


if not result_dir_path.exists():
    result_dir_path.mkdir(parents=True)


file_name = 'tweet_anime.txt'
with open(data_dir_path.joinpath(file_name.replace('.txt', '_cut.txt')), 'r', encoding='utf-8') as file:
    texts = file.readlines()
texts = [text.replace('\n', '') for text in texts]


dictionary = gensim.corpora.Dictionary.load_from_text(corpus_dir_path.joinpath(file_name.replace('.txt', '_dictionary.dict.txt')).__str__())
corpus = gensim.corpora.MmCorpus(corpus_dir_path.joinpath(file_name.replace('.txt', '_corpus.mm')).__str__())
model = gensim.models.ldamodel.LdaModel.load(model_dir_path.joinpath(file_name.replace('.txt', '_lda.pkl')).__str__())


# 文章ごとのトピック分類結果を得る
topics = [model[c] for c in corpus]

def sort_(x):
    return sorted(x, key=lambda x_:x_[1], reverse=True)

# トピック数を取得
num_topics = model.get_topics().shape[0]

# 文章間の類似度を測定するためにコサイン類似度の行列を作成
# 各文章を行として、トピックの重みを格納
dences = np.zeros((len(topics), num_topics), dtype=np.float)
for row, t_ in enumerate(topics):
    for col, value in t_:
        dences[row, col] = value

# 文章間のコサイン類似度の計算
cosine_ = cosine_similarity(dences, dences)
for i in range(cosine_.shape[0]):
    # 対角成分は類似度が1となるため、0に修正
    cosine_[i, i] = 0

#
# 文章の比較
#
target_doc_id = 4
print(texts[target_doc_id])
print(sort_(topics[target_doc_id]))

print(texts[int(np.argmax(cosine_[target_doc_id]))])
print(sort_(topics[int(np.argmax(cosine_[target_doc_id]))]))

#
# 各トピックの要素の表示
#
topic10 = []
for topic_ in model.show_topics(-1, formatted=False):
    topic10.append([token_[0] for token_ in topic_[1]])

topic10 = pd.DataFrame(topic10)
print(topic10)
topic10.to_csv(result_dir_path.joinpath(file_name.replace('.txt', '_topic10.csv')).__str__(),
               index=False, encoding='utf-8')



参考文献


今回は下記の本を参考にさせていただきました。今回は取り上げられなかったですが、LDAにおけるトピック数の自動決定に関しても触れられています。


Pythonでクラスタリング k-meansからk-medoidsを改良する

今回は、答えのないデータから、データの構造を見えるようにするクラスタリングについて述べていきます。クラスタリングとは、データが似ているものを一つのクラスタにまとめて情報を集約することによって、見通しを良くするものです。例えば、人の特徴を一人一人見るよりは、性別や世代にまとめて比較した方がわかりやすいです。


クラスタリングでよく使われるのはk-meansであり、k-meansに関する詳しいことは様々なところで述べられています。なので、このエントリではk-meansではなく、k-medoidsという手法に焦点を当てます。k-medoidsを一言で言えば外れ値に強いです。詳しいことは後ほど見ていきます。


今回は、k-medoidsに関して、分類後のクラスタの評価初期化の改良クラスタ数の自動決定を行っていきます。本エントリでは階層クラスタリングについての説明はないため、クラスタリングと言った場合に非階層クラスタリングを指しています。今回のエントリーを読むと下図のようなことができます。


f:id:dskomei:20180402222501p:plain:w500
f:id:dskomei:20180402230416p:plain:w500



k-medoidsを学ぶ前にk-meansをおさらい 王道


クラスタリング手法の王様と言っても良いくらい有名なk-meansに関して、軽くおさらいをします。k-meansでは、分割したいクラスタ数を与えたら、各ノードがどこかのクラスタに分類されることを目指します。学生の頃の学年の切り替わりと共に行われるクラス分けを思い出してもらえれば良いと思います。分割したいクラス数が事前に決まっていて、何かしらの基準によって分けられていました。あの頃は、気になる人と同じクラスになれるかドキドキしましたね。今思うと、同じクラスになれたということはクラスタリング的には距離が近かったということですね。それはさておき、分割したいクラスタ数を\(k\)として、k-meansの処理を以下に記述します。

  1. 各点をランダムに\(k\)個のクラスタのどれかに割り当てる
  2. 各クラスタの重心を求める
  3. 各点を一番近い重心のクラスタに再度割り当て直す
  4. 3.においてクラスタの再割当てをするノードがなければ終了し、あれば2.に戻る


f:id:dskomei:20180328231201p:plain:w550


pythonではk-measnは、sklearnライブラリに既に組み込まれてり、簡単にk-meansを実行することができます。先に結果のグラフを表示します。データ自体が分けやすいようになっていることもあり、分類したいクラスタ数に的確にクラスタリングされていることがわかります。


f:id:dskomei:20180329221054p:plain:w450


k-meansの実装は以下のようになります。

from pathlib import Path
import numpy as np
from sklearn.datasets import make_blobs
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans


image_dir_path = Path('./image')
if not image_dir_path.exists():
    image_dir_path.mkdir(parents=True)


# 分類したいクラスタ数をいろいろ変えて試してみる
n_clusters = [2, 3, 4, 5]
colors = ['lightgreen', 'orange', 'lightblue', 'hotpink', 'yellow']
markers = ['s',              'o',         'v',       'h',      'p']


for plot_num, n_cluster in enumerate(n_clusters):

    # 指定したクラスタ数のデータを作ってくれる
    # cluster_stdでデータの散らばり具合を調整できる
    x_data, labels = make_blobs(n_samples=150,
                                n_features=2,
                                centers=n_cluster,
                                cluster_std=1.5,
                                shuffle=True,
                                random_state=0)

    # k-meansは一行で行える
    # n_clustersでクラスタ数を指定し、initで初期化方法を指定している
    # initの初期化は「k-means++」であり、これは後ほど説明する
    km = KMeans(n_clusters=n_cluster, init='random')

    predicted_labels = km.fit_predict(x_data)
    centroids = km.cluster_centers_

    # クラスタリング結果の可視化
    plt.subplot(2, 2, plot_num+1)
    for i, label in enumerate(sorted(np.unique(predicted_labels))):
        
        # 分類した結果のクラスをプロット
        plt.scatter(x_data[predicted_labels==label, 0],
                    x_data[predicted_labels==label, 1],
                    c=colors[i],
                    marker=markers[i],
                    s=15)
    
    # 最終的なセントロイドをプロット
    plt.scatter(centroids[:, 0],
                centroids[:, 1],
                c='red',
                marker='*',
                s=70)

    plt.title('n cluster '+str(n_cluster))

plt.tight_layout()
plt.savefig(image_dir_path.joinpath('k_means.png').__str__())
plt.show()


ただ、k-meansには問題があります。全知全能な手法というものはなかなかないものです。では、その問題を次に見ていきたいと思います。


k-meansの問題 シンプルであるからこそ


k-meansの問題としては、以下のことがよく言われています。

  1. k-meansは座標をクラスタの重心(セントロイド)とするため、外れ値の影響を受けやすい
  2. k-meansの結果できたクラスタは、どのようになれば良いクラスタかを判断するかが明確ではない
  3. クラスタリングの結果は初期化時のランダム処理に依存する
  4. 最初にクラスタ数を指定しなければいけないが、クラスタ数は決まっていないことが多い
    言うなれば、よく分類してくれればクラスタ数は何でも良い


1.を解決するのがまさしく『k-medoids』です。そこで、k-medoisとはうんたらという話に入っていくわけですが、2.~4.に関しては、k-meansの問題というよりはクラスタリングで起こる問題であり、k-medoidsでも考慮しなければいけないことです。そこで、これらに関しては、k-meansで問題を解決するために行われた改良をk-medoidsに取り込んでいくという形で両方見ていきます。炭水化物 on 炭水化物という広島焼き戦法です。


k-medoidsとは


k-meansはよく聞いたことがあるけど、k-medoidsは全く聞いたことがないからといって力むことはありません。言っていることの本質はk-meansと変わりませんし、方法も似通っています。それでは処理を見てみます。先ほどと同じように、分割したいクラスタ数を\(k\)とします。

  1. \(k\)個のクラスタ数分の点をランダムに選択する(medoid)
  2. 各点を一番近いmedooidのクラスタに割り当てる
  3. それぞれのクラスタ内において、クラスタ内の他のすべての点との距離の合計が最小になる点を新たにmedoidとする
  4. 3.においてmedoidに変化がなければ終了し、あれば2.に戻る

 クラスタ\(i\)の要素の集合を\(X_i \)とし、距離関数を\(d()\)とすると、新しいmedoidを求める式は次式になります。


\( \displaystyle \arg \min_{x \in X_{i}}{ \sum_{y \in (X_{i}-{x})} d(x, y)} \)


f:id:dskomei:20180328234514p:plain:w550
 

ただ、k-medoidsはsklearnの中にはまだないようです。そこで、k-medoidsを自作してみたいと思います。k-meansと同じように実行できるように、class化します。

from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.datasets import make_blobs
from scipy.spatial.distance import pdist, squareform
import matplotlib.pyplot as plt


image_dir_path = Path('./image')
if not image_dir_path.exists():
    image_dir_path.mkdir(parents=True)


class KMedoids(object):

    def __init__(self, n_cluster,
                 max_iter=300):

        self.n_cluster = n_cluster
        self.max_iter = max_iter

    def fit_predict(self, D):

        m, n = D.shape

        initial_medoids = np.random.choice(range(m), self.n_cluster, replace=False)
        tmp_D = D[:, initial_medoids]

        # 初期セントロイドの中で距離が最も近いセントロイドにクラスタリング
        labels = np.argmin(tmp_D, axis=1)

        # 各点に一意のIDが振ってあった方が分類したときに取り扱いやすいため
        # IDをもつようにするデータフレームを作っておく
        results = pd.DataFrame([range(m), labels]).T
        results.columns = ['id', 'label']

        col_names = ['x_' + str(i + 1) for i in range(m)]
        # 各点のIDと距離行列を結びつかせる
        # 距離行列の列に名前をつけて後々処理しやすいようにしている
        results = pd.concat([results, pd.DataFrame(D, columns=col_names)], axis=1)

        before_medoids = initial_medoids
        new_medoids = []

        loop = 0
        # medoidの群に変化があり、ループ回数がmax_iter未満なら続く
        while len(set(before_medoids).intersection(set(new_medoids))) != self.n_cluster and loop < self.max_iter:

            if loop > 0:
                before_medoids = new_medoids.copy()
                new_medoids = []

            # 各クラスタにおいて、クラスタ内の他の点との距離の合計が最小の点を新しいクラスタとしている
            for i in range(self.n_cluster):
                tmp = results.ix[results['label'] == i, :].copy()

                # 各点において他の点との距離の合計を求めている
                tmp['distance'] = np.sum(tmp.ix[:, ['x_' + str(id + 1) for id in tmp['id']]].values, axis=1)
                tmp = tmp.reset_index(drop=True)
                # 上記で求めた距離が最初の点を新たなmedoidとしている
                new_medoids.append(tmp.loc[tmp['distance'].idxmin(), 'id'])

            new_medoids = sorted(new_medoids)
            tmp_D = D[:, new_medoids]

            # 新しいmedoidのなかで距離が最も最小なクラスタを新たに選択
            clustaling_labels = np.argmin(tmp_D, axis=1)
            results['label'] = clustaling_labels

            loop += 1

        # resultsに必要情報を追加
        results = results.ix[:, ['id', 'label']]
        results['flag_medoid'] = 0
        for medoid in new_medoids:
            results.ix[results['id'] == medoid, 'flag_medoid'] = 1
        # 各medoidまでの距離
        tmp_D = pd.DataFrame(tmp_D, columns=['medoid_distance'+str(i) for i in range(self.n_cluster)])
        results = pd.concat([results, tmp_D], axis=1)

        self.results = results
        self.cluster_centers_ = new_medoids

        return results['label'].values


# 分類したいクラスタ数をいろいろ変えて試してみる
n_clusters = [2, 3, 4, 5]
colors = ['lightgreen', 'orange', 'lightblue', 'hotpink', 'yellow']
markers = ['s',              'o',         'v',       'h',      'p']


for plot_num, n_cluster in enumerate(n_clusters):

    # 指定したクラスタ数のデータを作ってくれる
    # cluster_stdでデータの散らばり具合を調整できる
    x_data, labels = make_blobs(n_samples=150,
                                n_features=2,
                                centers=n_cluster,
                                cluster_std=1.5,
                                shuffle=True,
                                random_state=0)

    # k-meansと同じように初期化ではクラスタ数を指定する
    km = KMedoids(n_cluster=n_cluster)

    # k-medoidsの利点として、座標がなくても距離行列があればクラスタリングができるので
    # (k-meansの場合は座標が必要)行列を入力データとしている
    # そのため、データを行列に変えている
    D = squareform(pdist(x_data, metric='euclidean'))
    predicted_labels = km.fit_predict(D)
    centroids = km.cluster_centers_

    # クラスタリング結果の可視化
    plt.subplot(2, 2, plot_num+1)
    for i, label in enumerate(sorted(np.unique(predicted_labels))):

        # 分類した結果のクラスをプロット
        plt.scatter(x_data[predicted_labels==label, 0],
                    x_data[predicted_labels==label, 1],
                    c=colors[i],
                    marker=markers[i],
                    s=15)

    # medoidはノード番号なため、元データからプロット座標を得ている
    plt.scatter(x_data[centroids, 0],
                x_data[centroids, 1],
                c='red',
                marker='*',
                s=70)

    plt.title('n cluster '+str(n_cluster))

plt.tight_layout()
plt.savefig(image_dir_path.joinpath('k_medoids.png').__str__())
plt.show()


実行結果は以下のグラフのようになりました。k-meansによるクラスタリングと結果は違わないように見えます。


f:id:dskomei:20180329224300p:plain:w450


k-meansとk-medoidsは何が違うかをおさらいすると、クラスタリングの基点となる点を座標とするのがk-meansで、点そのものとするのがk-medoidsです。k-meansは座標を見るため外れ値の座標の影響を受けますが、k-medoidsは点そのもので見るため遠い距離にある外れ値の影響を受けません。これは、平均値と中央値の関係によく似ています。


また、k-medoidsには利点がもうひとつあります。それは、座標がわからなくても各点の距離が分かればクラスタリングできることです。このことは集合での距離にもとづいてクラスタリングをしたいときに便利です。集合での距離作る例としてJaccard係数(*2)があります。距離行列だけわかればいいので、実際の座標がなくても距離行列ができるように自作すれば、クラスタリングできるようになります。


クラスタリングの評価 直感と数字の間に悩む


k-means、k-medoidsの両方でクラスタリングをできるようになったわけですが、どのクラスタリングがいいかをどのように決めれば良いでしょうか。デザイナ的視点の強い方は、人の顔に近いようなクラスタに魅了されるかもしれませんし、引っ込み思案の方は一つの要素で独立しているクラスタに何かしらのシンパシーを感じるかもしれません。そんな事を言っていると、一向に決まりそうにありませんので、やはりこれだという指標がほしいですよね。


クラスタリングということなので、クラスタ内の点がまとまっていたら良さそうですね。言い換えるならば、クラスタ内の基点の中心付近に集まっていたら良さそうですよね。つまり、各クラスタ内において、セントロイド(or medoid)からの距離の合計が小さければ良いクラスタリングということになります。これは、クラスタ内誤差平方和として表されます。ここで、クラスタを\(i\)として、\(k\)はクラスタ数、\(X_i\)はクラスタ\(i\)の要素の集合、\(\mu_i\)はクラスタ\(i\)のセントロイド(or medoid)を表しています。


\( \displaystyle \sum_{i}^{k} \sum_{x \in X_i} d(x, \mu_{i})^2\)


クラスタを評価できるようになったことで、クラスタ数をいくつにすればよいかといことにも何かしらの情報を得られます。つまり、クラスタ数を変えて誤差平方和を比較すればよいのです。これをエルボー法と呼びます。一つの指標があるだけでも見えるようになることで思考が前進します。それでは、以下でエルボー法の例を見てみましょう。


f:id:dskomei:20180330085402p:plain:w450
 

今回はこれを実装するにあたって、k-medoidsの実装クラスをもつk_medoids.pyというファイル作り(最終的なコード)、最初にインポートしています。そのk-medoidsのクラス内に、クラスタ内誤差平方和(sse)を計算するclass関数を用意しています。

#
# クラスタ内誤差平方和を求める関数
#
def sse(self, distances, predicted_values, medoids):

    unique_labels = sorted(np.unique(predicted_values))

    sse = []
    for label, medoid in zip(unique_labels, medoids):
        # 各クラスタの中心からの距離
        distance = distances[medoid, predicted_values == label]
        distance_squared = distance * distance
        sse.extend(distance_squared.tolist())
    return np.sum(sse)


エルボー法でクラスタ内におけるまとまり具合を確認したわけですが、他のクラスタとの関連性を踏まえた指標も見ておきたいですね。クラスタ内ではまとまっているように見えても、ある点では他のクラスタの要素との距離の方が近かったということもあるかもしれません。まさしく、内通者がいるような状況かもしれませんね。そこで、クラスタ内の各点との距離(凝集度)と最も近いクラスタ内の各点との距離(乖離度)を比較してみることにします。凝集度が乖離度より小さければ、良いクラスタということになります。この比較方法がシルエット法です。以下がそのプロセスです。

  1. サンプル\(x_{i}\)とそのサンプルのクラスタ内との平均距離を求める
    これを、凝集度と呼ぶ
  2. サンプル\(x_{i}\)に最も近いクラスタ内のすべてのサンプルとの平均距離を求める
    これを、乖離度と呼ぶ
  3. クラスタの凝集度と乖離度との差を、それらのうちの大きい方で割り、シルエット係数を求める

 \(s_{i}\)をシルエット係数とすると、次式になる。


\( \displaystyle s_i= \frac{b^{i}-a^{i}}{ \max (b^i, a^i)} \)


シルエット係数の結果を見てみましょう。


f:id:dskomei:20180402222501p:plain:w500


赤い点線がシルエット係数の平均を表しています。グラフでのシルエット係数の分布に加えて、シルエット係数の平均を比較することでクラスタリングの良し悪しを判断できます。今回の結果からすると、一番良いクラスタリングといえるのはクラスタ数が2のときのようです。今回のコードは以下になります。

from pathlib import Path
import numpy as np
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans
from k_medoids import KMedoids
from scipy.spatial.distance import pdist, squareform
import matplotlib.pyplot as plt
from matplotlib import cm
import math


image_dir_path = Path('./image')


#
# シルエット係数を求める関数
#
def silhouette_samples(distances, labels):

    n_unique_label = sorted(np.unique(labels))
    n_samples_per_label = np.bincount(labels, minlength=len(n_unique_label))

    intra_clust_dists = np.zeros(distances.shape[0], dtype=distances.dtype)
    inter_clust_dists = np.inf + intra_clust_dists

    for curr_label in range(len(n_unique_label)):

        mask = labels == curr_label
        current_distances = distances[mask]

        # クラス内の対象点以外の点数のため-1をしている
        n_samples_curr_lab = n_samples_per_label[curr_label] - 1
        if n_samples_curr_lab != 0:
            # クラス内の平均距離の集計
            intra_clust_dists[mask] = np.sum(current_distances[:, mask], axis=1) / n_samples_curr_lab

        # 対象点と対象点が属さないクラスの最小平均距離を求めている
        for other_label in range(len(n_unique_label)):
            if other_label != curr_label:
                other_mask = labels == other_label
                other_distances = np.mean(current_distances[:, other_mask], axis=1)
                inter_clust_dists[mask] = np.minimum(inter_clust_dists[mask], other_distances)

    sil_samples = inter_clust_dists - intra_clust_dists
    sil_samples /= np.maximum(intra_clust_dists, inter_clust_dists)
    # クラスタ内の大きさが1のものは0に変換
    sil_samples[n_samples_per_label.take(labels) == 1] = 0
    return sil_samples



n_clusters = np.arange(2, 11, 1)
base_num = int(math.sqrt(len(n_clusters)))

# シルエット係数をクラスタ数間で比較するため、x軸をシェアする
fig, axs = plt.subplots(base_num, base_num, figsize=(8, 6), sharex=True)

for plot_num, n_cluster in enumerate(n_clusters):

    x_data,  labels = make_blobs(n_samples=150,
                                 n_features=2,
                                 centers=n_cluster,
                                 cluster_std=1.,
                                 shuffle=True,
                                 random_state=0)

    km = KMedoids(n_cluster=n_cluster,
                  max_iter=300)

    D = squareform(pdist(x_data, metric='euclidean'))

    predicted_labels = km.fit_predict(D)

    unique_labels = np.unique(predicted_labels)
    unique_labels.sort()

    # シルエット係数を求める
    silhouette_values = silhouette_samples(D, predicted_labels)

    ax = axs[math.floor(plot_num / base_num), int(plot_num % base_num)]
    y_ax_lower, y_ax_upper = 0, 0
    yticks = []
    for i, label in enumerate(unique_labels):

        silhouette_values_ = silhouette_values[predicted_labels == label]
        silhouette_values_.sort()

        color = cm.jet(i / n_cluster)
        y_ax_upper += len(silhouette_values_)
        
        ax.barh(range(y_ax_lower, y_ax_upper),
                silhouette_values_,
                height=1.0,
                edgecolor='none',
                color=color)


        yticks.append((y_ax_lower + y_ax_upper) / 2)
        y_ax_lower += len(silhouette_values_)

    silhouette_avg = np.mean(silhouette_values)
    print('clusters {} , silhouette avg : {:.2f}'.format(n_cluster,
                                                         silhouette_avg))
    # シルエット係数の平均値を点線でプロット
    ax.axvline(silhouette_avg,
                color='red',
                linestyle='--')

    ax.set_yticks(yticks)
    ax.set_yticklabels(unique_labels+1)
    ax.set_title('n cluster : '+str(n_cluster))

plt.tight_layout()
plt.savefig(image_dir_path.joinpath('shilhouette.png').__str__())
plt.show()


シルエット係数は1に近いほどよいのですが、今回はクラスタ数が2のときが最高値であり、それでも0.55でした。もう少し改善したいところです。こういうときは、原点に戻って考え直すのが一番ですね。ということで、初期化について次に考えてみます。


クラスタリングにおける初期化 はじめの一歩でケリをつける


クラスタリングは基点となる点に基づいて行われるため、基点の位置で結果が変わります。なので、最初にどの点を基点として始めるかで結果の良し悪しは異なってきます。つまり、初期化が重要です。初期化されたところから修正していくため、最初に変なところにあったら、修正と言ってもたかが知れています。議題の設定が抽象的すぎで話をしても何も決まらないMTGと同じです。MTGは最初の議題でほぼ決まりですよね。クラスタリングにおける適切な初期化とは、基点同士にばらつきがあることです。その方法のひとつが「k-means++」です。以下がプロセスです。

  1. すべての点の中からセントロイドをランダムに一つ選択する
  2. 残りの点において、最近傍中心との距離\(D(x)\)を計算する
  3. 残りのデータ点から確率分布\( \frac{D(x)^2}{\sum D(x)^2}\)に従いランダムに一つ点を選択
  4. 指定したクラスタ数になるまで2.3.を繰り返す


ここで最近傍中心とは、ある点においてセントロイドの中で最も近いもののことを指します。ランダム選択の確率分布は最近傍中心との距離が大きくなるほど選択されやすくなっています。つまり、k-means++でやろうとしている初期化は、ランダム性を保ちながらセントロイド同士の距離感を近すぎないように保つことです。まさしく、西部劇での早打ちの決闘ですね。これまでのランダムにより選択された単純な初期化のセントロイドと上記の方法に基づいた初期化のセントロイドを比較してみましょう。


f:id:dskomei:20180402230416p:plain:w500
 

k-means++の初期化の方が単純なランダムな初期化よりもセントロイドにばらつきがあることが見られました。初期化が改善されていることがわかります。実際は、複数個の初期化からクラスタリングしたときにもっともクラスタ内誤差が小さかったクラスタリング結果を選ぶようです。上記の初期化方法を実装したものを下記に記載します。k-medoidsで使うために、入力データは距離行列に変換しています。

from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.datasets import make_blobs
from scipy.spatial.distance import pdist, squareform
import matplotlib.pyplot as plt


image_dir_path = Path('./image')

#
# k-means++の初期化を行う関数 
#
def making_initial_medoids(distances, n_cluster):

    m, n = distances.shape

    distances_pd = pd.DataFrame({'id':range(m)})
    distances_pd = pd.concat([distances_pd, pd.DataFrame(distances,
                                                         columns=[i for i in range(n)])], axis=1)

    medoids = []
    for cluster_num in range(n_cluster):

        if cluster_num == 0:
            medoid = np.random.randint(0, m, size=1)
            medoids.extend(medoid)
        else:
            distance = distances_pd.drop(medoids, axis=0)
            distance = distance.ix[:, ['id'] + medoids]
            # 最近傍中心を求めている
            distance['min_distance'] = distance.min(axis=1)
            distance['min_distance_squared'] = distance['min_distance']*distance['min_distance']
            ids = distance['id'].values
            # 確率分布を求めている
            distance_values = distance['min_distance_squared'] / np.sum(distance['min_distance_squared'])
            medoid = ids[np.random.choice(range(ids.size), 1, p=distance_values)]
            medoids.extend(medoid)

    medoids = sorted(medoids)
    return medoids



if __name__ == '__main__':

    n_cluster = 3

    x_datas, labels = make_blobs(n_samples=150,
                                  n_features=2,
                                  centers=n_cluster,
                                  cluster_std=1.,
                                  shuffle=True,
                                  random_state=0)

    unique_labels = np.unique(labels)

    D = squareform(pdist(x_datas, metric='euclidean'))


    colors = ['lightgreen', 'orange', 'lightblue']
    markers = ['s', 'o', 'v']

    for plot_num in range(9):

        initial_plus2 = making_initial_medoids(D, n_cluster=n_cluster)
        initial_normal = np.random.choice(range(x_datas.shape[0]), n_cluster, replace=False)

        plt.subplot(3, 3, plot_num+1)


        for i, label in enumerate(unique_labels):

            plt.scatter(x_datas[labels == label, 0],
                        x_datas[labels == label, 1],
                        c=colors[i],
                        marker=markers[i],
                        s=5,
                        label='cluster %s'%label)

        plt.scatter(x_datas[initial_plus2, 0],
                    x_datas[initial_plus2, 1],
                    c='red',
                    marker='*',
                    s=100,
                    label='initial++')

        plt.scatter(x_datas[initial_normal, 0],
                    x_datas[initial_normal, 1],
                    c='green',
                    marker='*',
                    s=100,
                    label='initial normal')

    plt.legend(bbox_to_anchor=(1, 1), loc='best', borderaxespad=0)
    plt.tight_layout()
    plt.savefig(image_dir_path.joinpath('initial_plus2.png').__str__(), bbox_inches='tight')
    plt.show()


ただ、これはクラスタ数を固定して比較していますが、なぜこのクラスタ数なのかと言われると、答えに窮してしまいます。エルボー法やシルエット分析によって、グラフを見ながらクラスタ数を決めると言った試行錯誤をすれば確かにある程度の根拠を持って決められますが、やはり自動化したいですよね。では、クラスタ数決定の自動化に関して見てみましょう。


クラスタ数決定の自動化 寝てる間にクラスタリング


自動的にクラスタ数を決めると言っても、占い師が北に向かいなさいというようにデータを見たただけで最適なクラスタ数が点から降りてくるなんてことはありません。結局は、いくつかのクラスタ数を同一指標で比較して最も良い値だったクラスタ数を選択するということに他なりません。それを自動でやってくれるのであれば便利ですよね。今回はクラスタ数の自動決定アルゴリズムとして、x-meansを取り上げます。以下が処理方法です。こちらの論文を参考にしています。

  1. k-meansを使って2つのクラスタに分ける
  2. 分割したクラスタそれぞれにおいてk-meansで2つに分け、その際ににBICが良くなるならばそのままにし、悪くなるならば分割前のクラスタに戻して、このクラスタの分割を終了する
  3. 2.を再帰的に繰り返し、分割が終われば終了


f:id:dskomei:20180402233213p:plain:w500
 

今回はこれをk-medoids版に拡張したいと思います。とは言っても、距離行列に置き換えたときに上手く動作しなかったので、距離行列を入力データとしたx-medoidsは断念しました。なので、x-meansのk-meansの部分をk-medoidsに置き換えているだけです。早速実行結果を見てみましょう。


f:id:dskomei:20180403000235p:plain:w600


左のグラフが元のデータであり、そのデータをクラスタリングしており、右のグラフが同じクラスタ数に分類できておれば成功と言えます。上のグラフの通り、クラスタ数3~5において適切なクラスタ数に分類できていることがわかります。コードは以下のとおりです。

|from pathlib import Path
import numpy as np
from scipy import stats
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.cluster import KMeans
from k_medoids import KMedoids
from sklearn.datasets import make_blobs
from scipy.spatial.distance import pdist, squareform


image_dir_path = Path('./image')


class XMedoids:


    def __init__(self, n_cluster=2, max_iter=9999, n_init=1):

        self.n_cluster = n_cluster
        self.max_iter = max_iter
        self.n_init = n_init


    def fit(self, X):

        self.__clusters = []
        clusters = self.Cluster.build(X, KMedoids(n_cluster=self.n_cluster,
                                                  max_iter=self.max_iter,
                                                  n_init=self.n_init).fit(squareform(pdist(X))))
        self.__recursively_split(clusters)

        self.labels_ = np.empty(X.shape[0], dtype=np.intp)
        for i, c in enumerate(self.__clusters):
            self.labels_[c.index] = i

        self.cluster_centers_ = np.array([c.center for c in self.__clusters])
        self.cluster_log_likelihoods_ = np.array([c.log_likelihood() for c in self.__clusters])
        self.cluster_sizes_ = np.array([c.size for c in self.__clusters])

        return self



    def __recursively_split(self, clusters):

        for cluster in clusters:
            if cluster.size <= 3:
                self.__clusters.append(cluster)
                continue

            k_means = KMedoids(2, max_iter=self.max_iter, n_init=self.n_init).fit(squareform(pdist(cluster.data)))
            c1, c2 = self.Cluster.build(cluster.data, k_means, cluster.index)

            beta = np.linalg.norm(c1.center - c2.center) / np.sqrt(np.linalg.det(c1.cov) + np.linalg.det(c2.cov))
            alpha = 0.5 / stats.norm.cdf(beta)
            bic = -2 * (cluster.size * np.log(
                alpha) + c1.log_likelihood() + c2.log_likelihood()) + 2 * cluster.df * np.log(cluster.size)

            if bic < cluster.bic():
                self.__recursively_split([c1, c2])
            else:
                self.__clusters.append(cluster)


    class Cluster:

        @classmethod
        def build(cls, X, cluster_model, index=None):

            if index is None:
                index = np.array(range(0, X.shape[0]))
            labels = range(0, len(np.unique(cluster_model.labels_)))

            return tuple(cls(X, index, cluster_model, label) for label in labels)

        # index: Xの各行におけるサンプルが元データの何行目のものかを示すベクトル
        def __init__(self, X, index, cluster_model, label):

            self.data = X[cluster_model.labels_ == label]
            self.index = index[cluster_model.labels_ == label]
            self.size = self.data.shape[0]
            self.df = self.data.shape[1] * (self.data.shape[1] + 3) / 2
            center_ = cluster_model.cluster_centers_[label]
            self.center = X[center_, :]
            self.cov = np.cov(self.data.T)



        def log_likelihood(self):
            return sum([stats.multivariate_normal.logpdf(x, self.center, self.cov) for x in self.data])


        def bic(self):
            return -2 * self.log_likelihood() + self.df * np.log(self.size)


if __name__ == "__main__":

    colors = ['lightgreen', 'orange', 'lightblue', 'hotpink', 'yellow', 'lightmon']
    markers = ['s', 'o', 'v', 'h', 'p', '+']
    plt.figure(figsize=(10, 6))

    n_clusters_list = np.arange(3, 6)
    for plot_num, n_clusters in enumerate(n_clusters_list):

        x_datas, labels = make_blobs(n_samples=200,
                                     n_features=2,
                                     centers=n_clusters,
                                     cluster_std=0.5,
                                     shuffle=True,
                                     random_state=0)

        # クラスタリングの実行
        km = XMedoids(n_cluster=2,
                      max_iter=300,
                      n_init=10).fit(x_datas)

        predicted_values = km.labels_
        medoids = km.cluster_centers_

        unique_labels = sorted(np.unique(labels))

        plt.subplot(len(n_clusters_list), 2, plot_num*2+1)
        for i, label in enumerate(unique_labels):

            plt.scatter(x_datas[labels==label, 0],
                        x_datas[labels==label, 1],
                        c=colors[i],
                        marker=markers[i],
                        s=15,
                        label='cluster %s'%label)
        plt.title('cluster %d : true data'%n_clusters)


        plt.subplot(len(n_clusters_list), 2, plot_num*2+2)
        for i, label in enumerate(unique_labels):

            plt.scatter(x_datas[predicted_values==label, 0],
                        x_datas[predicted_values==label, 1],
                        c=colors[i],
                        marker=markers[i],
                        s=15,
                        label='cluster %s'%label)

        plt.scatter(medoids[:, 0],
                    medoids[:, 1],
                    c='red',
                    marker='*',
                    s=100)
        plt.title('cluster %d : predicted data'%n_clusters)

    plt.tight_layout()
    plt.savefig(image_dir_path.joinpath('x_medoids.png').__str__())
    plt.show()



k-medoidsをクラス化した部分です。


class KMedoids(object):


    def __init__(self, n_cluster,
                 max_iter=300,
                 n_init=10):

        self.n_cluster = n_cluster
        self.max_iter = max_iter
        self.n_init = n_init


    def fit_predict(self, D):

        m, n = D.shape

        col_names = ['x_' + str(i + 1) for i in range(m)]

        best_results = None
        best_sse = np.Inf
        best_medoids = None
        for init_num in range(self.n_init):


            initial_medoids = np.random.choice(range(m), self.n_cluster, replace=False)
            tmp_D = D[:, initial_medoids]

            # 初期セントロイドの中で距離が最も近いセントロイドにクラスタリング
            labels = np.argmin(tmp_D, axis=1)

            # 各点に一意のIDが振ってあった方が分類したときに取り扱いやすいため
            # IDをもつようにするデータフレームを作っておく
            results = pd.DataFrame([range(m), labels]).T
            results.columns = ['id', 'label']


            # 各点のIDと距離行列を結びつかせる
            # 距離行列の列に名前をつけて後々処理しやすいようにしている
            results = pd.concat([results, pd.DataFrame(D, columns=col_names)], axis=1)

            before_medoids = initial_medoids
            new_medoids = []

            loop = 0
            # medoidの群に変化があり、ループ回数がmax_iter未満なら続く
            while len(set(before_medoids).intersection(set(new_medoids))) != self.n_cluster and loop < self.max_iter:

                if loop > 0:
                    before_medoids = new_medoids.copy()
                    new_medoids = []

                # 各クラスタにおいて、クラスタ内の他の点との距離の合計が最小の点を新しいクラスタとしている
                for i in range(self.n_cluster):
                    tmp = results.ix[results['label'] == i, :].copy()

                    # 各点において他の点との距離の合計を求めている
                    tmp['distance'] = np.sum(tmp.ix[:, ['x_' + str(id + 1) for id in tmp['id']]].values, axis=1)
                    tmp = tmp.reset_index(drop=True)
                    # 上記で求めた距離が最初の点を新たなmedoidとしている
                    new_medoids.append(tmp.loc[tmp['distance'].idxmin(), 'id'])

                new_medoids = sorted(new_medoids)
                tmp_D = D[:, new_medoids]

                # 新しいmedoidのなかで距離が最も最小なクラスタを新たに選択
                clustaling_labels = np.argmin(tmp_D, axis=1)
                results['label'] = clustaling_labels

                loop += 1

            # resultsに必要情報を追加
            results = results.ix[:, ['id', 'label']]
            results['flag_medoid'] = 0
            for medoid in new_medoids:
                results.ix[results['id'] == medoid, 'flag_medoid'] = 1
            # 各medoidまでの距離
            tmp_D = pd.DataFrame(tmp_D, columns=['medoid_distance'+str(i) for i in range(self.n_cluster)])
            results = pd.concat([results, tmp_D], axis=1)

            sse = self.sse(distances=D, predicted_values=results['label'].values, medoids=new_medoids)

            if sse < best_sse:
                best_sse = sse
                best_results = results.copy()
                best_medoids = new_medoids.copy()

        self.labels_ = best_results['label'].values
        self.results = best_results
        self.cluster_centers_ = np.array(best_medoids)
        self.inertia_ = best_sse

        return self.labels_


    def fit(self, D):

        m, n = D.shape

        col_names = ['x_' + str(i + 1) for i in range(m)]

        best_results = None
        best_sse = np.Inf
        best_medoids = None
        for init_num in range(self.n_init):

            initial_medoids = making_initial_medoids(D, n_cluster=self.n_cluster)
            tmp_D = D[:, initial_medoids]

            # 初期セントロイドの中で距離が最も近いセントロイドにクラスタリング
            labels = np.argmin(tmp_D, axis=1)
            results = pd.DataFrame([range(m), labels]).T
            results.columns = ['id', 'label']

            results = pd.concat([results,
                                 pd.DataFrame(D, columns=col_names)], axis=1)


            before_medoids = initial_medoids
            new_medoids = []

            loop = 0
            while len(set(before_medoids).intersection(set(new_medoids))) != self.n_cluster and loop < self.max_iter:

                if loop > 0:
                    before_medoids = new_medoids.copy()
                    new_medoids = []

                for i in range(self.n_cluster):
                    tmp = results.ix[results['label'] == i, :].copy()
                    tmp['distance'] = np.sum(tmp.ix[:, ['x_' + str(id + 1) for id in tmp['id']]].values, axis=1)
                    tmp.reset_index(inplace=True)
                    new_medoids.append(tmp.loc[tmp['distance'].idxmin(), 'id'])

                new_medoids = sorted(new_medoids)
                tmp_D = D[:, new_medoids]
                labels = np.argmin(tmp_D, axis=1)
                results['label'] = labels

                loop += 1

            results = results.ix[:, ['id', 'label']]
            results['flag_medoid'] = 0
            for medoid in new_medoids:
                results.ix[results['id'] == medoid, 'flag_medoid'] = 1
            tmp_D = pd.DataFrame(tmp_D, columns=['label' + str(i) + '_distance' for i in range(self.n_cluster)])
            results = pd.concat([results, tmp_D], axis=1)

            sse = self.sse(distances=D, predicted_values=results['label'].values, medoids=new_medoids)

            if sse < best_sse:
                best_sse = sse
                best_results = results.copy()
                best_medoids = new_medoids.copy()

        self.results = best_results
        self.cluster_centers_ = np.array(best_medoids)
        self.labels_ = self.results['label'].values

        return self


    #
    # クラス内誤差平方和を求める関数
    #
    def sse(self, distances, predicted_values, medoids):

        unique_labels = sorted(np.unique(predicted_values))

        sse = []
        for label, medoid in zip(unique_labels, medoids):
            # 各クラスタの中心からの距離
            distance = distances[medoid, predicted_values == label]
            distance_squared = distance * distance
            sse.extend(distance_squared.tolist())
        return np.sum(sse)



参考文献


k-meansに関しては詳しことがすでに他の文献で書かれており、パッケージ化もされており、以下の本で詳しいことが書かれています。



*1http://www.cs.cmu.edu/~dpelleg/download/xmeans.pdf
*2https://media.accel-brain.com/jaccard-dice-simpson/

Pythonを使って変数選択!

 
機械学習はデータが命です。データが精度を左右するので、精度を上げるためにデータを増やし、変数をどんどん追加してくという方向になりがちです。しかし、変数の数を多くすると、計算時間の増加をまねいたり、特定のクラスの一部のデータの影響で過学習したりなどの問題が起こります。


意味のある変数だけを抽出できたり、次元を減らすように要約できたりすれば、重要な要因がわかりますし、計算時間も減らせます。見たい番組が多すぎて色々ザッピングした結果、何も記憶に残っていないみたいなことがなくなります。今回は、このような変数の削減方法において見ていきます。


先に実装結果を示すと、各手法によって選択される変数が異なるため、同一の機械学習アルゴリズムで同一パラメータにおいてもテストデータの正答率が異なっています。今回は変数増加法の正答率が一番高く、もとの変数の1/2以下になっています。


f:id:dskomei:20180319230154p:plain:w550


f:id:dskomei:20180319230215p:plain:w550



今回取り上げる手法(各種法をクリックすれば飛びます)



変数選択と次元削減


変数を減らすためには2種類の方法があります。それは、変数選択と次元削減です。変数選択は全変数群から変数を抜き取る方法であり、各変数の値は変わりません。それに対して次元削減は、次元を集約して新たな次元を作ることで変数を減らします。例えば、何十人の臣下を抱える王様だと、良いことを言う臣下を数人選択するのが変数選択であり、臣下の言っていることをいくつかのテーマでまとめるのが次元削減です。


実装のための準備


データの前処理


今回はUCI Machin Learning Repositoryから『Bank Marketing Data Set』のデータを拝借し、分類問題において変数削減によって正答率の向上を目指します。このデータは、ポルトガルの銀行で電話によるダイレクトマーケティングした際に、デポジットの契約をしたか否かと、その時の情報を16個の特徴量で表しています。今回使用したデータはここからダウンロードできます。リンク先のbank.zipの中に「bank.csv」、「bank-full.csv」があります。bank.csvとbank-full.csvは変数は同じですが、レコード数が異なります。今回はbank-full.csvのデータを使います。


f:id:dskomei:20180319231835p:plain


図を見てわかる通り、特徴量の中には9個のカテゴリ変数があり、このカテゴリ変数を入力データとして使うにはダミー変数に変換しなければいけません。計算機での計算は数字で行うため、急によくわからない文字列が与えられても計算できないからです。今回のデータでいえば、2つ目の変数の「job」には9種類のカテゴリがあるため、以下のように9つのダミー変数に替えます。


f:id:dskomei:20180319232057p:plain


またカテゴリ変数以外の変数の値は、変数によって上限がまちまちであり、変数間の比較ができません。人間とキリンの平均身長を比較しても意識高い系以外は興味もないでしょう。上限の値が大きい変数が少し違うだけで予測誤差が大きくなるので、予測誤差に対してその変数の影響が強くなります。そこで各変数を平均0、標準偏差1に正規化し変数間で比較できるようにします。
被説明変数は、契約したか否かの2択であり、これは0-1に変換します。以上のことをまとめると次のようなコードになります。

from pathlib import Path
import pandas as pd
from sklearn.preprocessing import StandardScaler
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC


output_dir_path = Path('./output')
if not output_dir_path.exists():
    output_dir_path.mkdir(parents=True)

# データを読み込んで、入力データと出力データに分割
datas = pd.read_csv('bank-full.csv', sep=';', encoding='utf-8')
x_datas = datas.drop('y', axis=1)
y_datas = pd.DataFrame(datas['y'], columns=['y'])

# ダミー変数に変換するカテゴリ変数の指定
categorical_variable_names = ['job', 'marital', 'education',
                               'default', 'housing', 'loan', 'contact',
                               'month','poutcome']
# カテゴリ変数に一括変換
x_dummy = pd.get_dummies(x_datas[categorical_variable_names])

x_datas.drop(categorical_variable_names, axis=1, inplace=True)

# それ以外の変数は正規化
x_datas_std = StandardScaler().fit_transform(x_datas)
x_datas_std = pd.DataFrame(x_datas_std, columns=x_datas.columns)

# 入力データの完成
x_datas = pd.concat([x_datas_std, x_dummy], axis=1)

# 出力データは0-1に変換し、1次元に整形
class_mapping = {label:idx for idx, label in enumerate(np.unique(y_datas))}
y_datas['y'] = y_datas['y'].map(class_mapping)
y_datas = y_datas.values.reshape(-1)

# データのうち3割をテストデータに分割
n_train_rate = 0.7
train_x_data, test_x_data, train_y_data, test_y_data = train_test_split(x_datas,
                                                                        y_datas,
                                                                        test_size=1-n_train_rate)



変数選択


単変量選択 単純なランキングこそ一番


単変量選択とは、ある指標のもとに変数を順位付けし、その順位に従って指定した数だけの変数を取得する方法です。オリコンランキングのCD売上げTOP10などです。順位ができてしまえば、上位から順番に選択していくだけです。
下の例では、「SelectKBest」を使っています。第一引数に順位をつける関数を入れ、引数kで選択する変数の数を指定します。分類と回帰では、第一引数に入れる関数が異なり、デフォルトでは分類用の「f_classcif」が入っています。これはF検定によって順位をつけています。分類、回帰それぞれで使える関数は以下の通りです。

  • 分類:「chi2」、「f_classif」、「mutual_info_classif」
  • 回帰:「f_regression」、「mutual_info_regression」
from sklearn.feature_selection import SelectKBest

col_names = train_x_data.columns

# 今回はSVMで学習(どの変数選択手法でも同じ)
model = SVC(kernel='rbf', C=1, gamma=0.1)

selected_col_names = []
scores = []
max_score = 0
selected_k = 0

# 変数の数
ks = np.arange(1, len(col_names)+1)
for k in ks:

    # k個の変数を選択する(評価関数はデフォルトのf_classif)
    skb = SelectKBest(k=k)
    skb.fit(train_x_data, train_y_data)

    # skbに基づいて入力データからK個変数を選択
    train_x_data_ = skb.transform(train_x_data)
    test_x_data_ = skb.transform(test_x_data)

    model.fit(train_x_data_, train_y_data)

    # skb.get_supprt()により選択された変数のインデックスを取得
    col_names_ = np.array(col_names)[skb.get_support()]
    score = model.score(test_x_data_, test_y_data)

    if score > max_score:
        selected_k = k
        max_score = score

    scores.append(score)
    selected_col_names.append(','.join(col_names_))

    print('k {:3} : score {:5.2f}%'.format(k, score*100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(selected_k, int(max_score*100)))
print(selected_col_names[int(np.argmax(scores))])


ks = [len(cols.split(',')) for cols in selected_col_names]
results = pd.DataFrame([ks,
                        scores,
                        selected_col_names]).T
results.columns = ['n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('ufs.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)



変数減少法 怪しいやつから消していこう


評価値においてモデルが改善する変数を1つずつ除去するという方法を、入力データの全変数から改善が終わるまで繰り返していきます。映画『インシテミル』(*1)のようですね。

col_names = train_x_data.columns.tolist()

model = SVC(kernel='rbf', C=1, gamma=0.1)

# 列名を減少させていくことで、入力データをヘンス指定で取得する
target_col_names = col_names.copy()
target_col_names = target_col_names[::-1]

base_score = -1       # 前ループの正答率
loop_max_score = 0    # 現ループの最大正答率
del_col_name = ''     # 削除変数

selected_col_names = []
scores = []

selected_k = 0
selected_score = 0

# 現ループの最大正答率が前ループより大きいかつ、選択できる変数があるならば続行
while (base_score < loop_max_score) and len(target_col_names) > 0:

    base_score = loop_max_score

    for target_col_name in target_col_names:
        col_names_ = target_col_names.copy()
        col_names_.remove(target_col_name)

        # 入力データから変数の選択
        train_x_data_ = train_x_data.loc[:, col_names_]
        test_x_data_ = test_x_data.loc[:, col_names_]

        model.fit(train_x_data_, train_y_data)
        score = model.score(test_x_data_, test_y_data)

        if score > loop_max_score:
            loop_max_score = score
            del_col_name = target_col_name

    if loop_max_score <= base_score:
        break

    if del_col_name in target_col_names:
        # 正答率を最も改善する変数を削除
        target_col_names.remove(del_col_name)
        selected_col_names.append(','.join(target_col_names))
        scores.append(loop_max_score)
        print('k {:3} : score {:5.2f}%'.format(len(target_col_names), loop_max_score * 100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(len(selected_col_names[int(np.argmax(scores))].split(',')),
                                              loop_max_score))
print(selected_col_names[int(np.argmax(scores))])


ks = [len(cols.split(',')) for cols in selected_col_names]
results = pd.DataFrame([ks,
                        scores,
                        selected_col_names]).T
results.columns = ['n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('backward_elimination.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)



変数増加法 信頼できるやつで固めてしまおう


変数増加法は変数減少法と名前が似ている通り、変数減少法とは反対の方法です。この方法では、モデルを改善する変数を1つずつ追加していくのを改善が終わるまで繰り返します。

col_names = train_x_data.columns.tolist()

model = SVC(kernel='rbf', C=1, gamma=0.1)

target_col_names = []              # 選択変数の格納
loop_col_names = col_names.copy()  # 未選択変数を格納

base_score = -1
loop_max_score = 0
additional_col_name = ''

selected_col_names = []
scores = []

selected_k = 0
selected_score = 0

# 現ループが前ループより正答率が高くかつ、未選択変数が残されているならば続行
while (base_score < loop_max_score) and len(loop_col_names) > 0:

    base_score = loop_max_score

    for target_col_name in loop_col_names:
        col_names_ = target_col_names.copy()
        col_names_.append(target_col_name)

        # 入力データから変数の選択
        train_x_data_ = train_x_data.loc[:, col_names_]
        test_x_data_ = test_x_data.loc[:, col_names_]

        model.fit(train_x_data_, train_y_data)
        score = model.score(test_x_data_, test_y_data)

        if score > loop_max_score:
            loop_max_score = score
            additional_col_name = target_col_name

    if base_score >= loop_max_score:
        break

    # 未選択変数群から選択分数を削除
    loop_col_names.remove(additional_col_name)
    target_col_names.append(additional_col_name)
    selected_col_names.append(','.join(target_col_names))
    scores.append(loop_max_score)
    print('k {:3} : score {:5.2f}%'.format(len(target_col_names), loop_max_score * 100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(len(selected_col_names[int(np.argmax(scores))].split(',')),
                                              loop_max_score))
print(selected_col_names[int(np.argmax(scores))])


ks = [len(cols.split(',')) for cols in selected_col_names]
results = pd.DataFrame([ks,
                        scores,
                        selected_col_names]).T
results.columns = ['n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('forward_selection.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)



ロジスティック回帰のL1正則化


機械学習において過学習を防ぐために、目的関数にペナルティ項を加えることがよくあります。このペナルティ項として、L1ノルムと呼ぼれる重みパラメータの絶対値の合計を使うのがL1正則化です。L1正則化を使うことで、スパースなパラメータを得やすくなります。パラメータが閾値よりも低いものは、学習器に与える影響が小さいということで、削ることができます。詳しいことはこちらをRでL1 / L2正則化を実践する - 渋谷駅前で働くデータサイエンティストのブログ。L1、L2正則化に関して図示されなが述べられているので、非常に参考になります。

from sklearn.svm import LinearSVC
from sklearn.feature_selection import SelectFromModel

col_names = train_x_data.columns.values

model = SVC(kernel='rbf', C=1, gamma=0.1)

selected_col_names = []
scores = []
selected_k = 0
max_score = 0

# ロジスティック回帰のハイパーパラメータ
Cs = [10**i for i in range(-3, 3, 1)]

for C in Cs:

    # L1正則化でロジスティック回帰を学習します
    lsvc = LinearSVC(C=C, penalty='l1', dual=False).fit(train_x_data, train_y_data)
    slm = SelectFromModel(lsvc, prefit=True)

    # 選択変数の抽出
    train_x_data_ = train_x_data.loc[:, slm.get_support()]
    test_x_data_ = test_x_data.loc[:, slm.get_support()]

    model.fit(train_x_data_, train_y_data)
    score = model.score(test_x_data_, test_y_data)

    col_names_ = col_names[slm.get_support()]
    if score > max_score:
        max_score = score
        selected_k = len(col_names_)

    selected_col_names.append(','.join(col_names_))
    scores.append(score)

    print('C {:5} : k {:3} : score {:5.2f}%'.format(C, len(col_names_), max_score * 100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(selected_k, max_score))
print(selected_col_names[int(np.argmax(scores))])

ks = [len(cols.split(',')) for cols in selected_col_names]
results = pd.DataFrame([Cs,
                        ks,
                        scores,
                        selected_col_names]).T
results.columns = ['C', 'n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('lsvc.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)



ランダムフォレストの変数重要度による変数選択 インフルエンサに気をつけろ


詳細に述べると非常に長くなりそうなので、概念的な部分だけを抑えたいと思います。詳細はRandom Forestで計算できる特徴量の重要度 - なにメモで説明されており、今回調べる上で非常に勉強になりました。ランダムフォレストでは、複数の決定木を作成し、その決定木の多数決などで分類しますが、このとき学習された複数の決定木において、変数の値をぐちゃぐちゃにしたときにどのくらい精度が悪くなるかで変数の重要度を測定しています。ぐちゃぐちゃしたときに精度が落ちる変数ほど、推測するうえでキーになっているということです。日頃でたらめなことを言っている人が何を言っても影響は少ないですが、誰もが信頼している人が嘘をついたときの影響は大きいといった感じでしょうか。

from sklearn.ensemble import RandomForestClassifier

col_names = train_x_data.columns.values

model = SVC(kernel='rbf', C=1, gamma=0.1)

selected_col_names = []
scores = []
selected_k = 0
max_score = 0

rf = RandomForestClassifier()
rf.fit(train_x_data, train_y_data)

# ランダムフォレストによる変数重要度順に並び替え
col_names_ = col_names[np.argsort(rf.feature_importances_)[::-1]]

for n_variable in range(1, len(col_names)+1):

    # 指定した変数分を重要度が高いものから選択
    train_x_data_ = train_x_data.loc[:, col_names_[:n_variable]]
    test_x_data_ = test_x_data.loc[:, col_names_[:n_variable]]

    model.fit(train_x_data_, train_y_data)
    score = model.score(test_x_data_, test_y_data)

    if score > max_score:
        max_score = score
        selected_k = n_variable

    selected_col_names.append(','.join(col_names_[:n_variable]))
    scores.append(score)

    print('k {:3} : score {:5.2f}%'.format(n_variable, max_score * 100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(selected_k, max_score))
print(selected_col_names[int(np.argmax(scores))])

ks = [len(cols.split(',')) for cols in selected_col_names]
results = pd.DataFrame([ks,
                        scores,
                        selected_col_names]).T
results.columns = ['n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('randomforest_importance.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)



次元削減


主成分分析 ギャップ萌えこそ良い軸


言わずわずとしれた主成分分析です。分散が大きいものほど情報を持っているということで、分散が大きくなる軸を作り、その軸を使ってデータを変換します。その際に、何番目までの軸を使うかで次元を決めていきます。詳しくはこちらを主成分分析の考え方 | Logics of Blue

from sklearn.decomposition import PCA

model = SVC(kernel='rbf', C=1, gamma=0.1)

selected_col_names = []
scores = []
selected_k = 0
max_score = 0

for n_variable in range(1, train_x_data.shape[1]+1):

    pca = PCA(n_components=n_variable)
    pca.fit(x_datas)
    train_x_data_ = pca.transform(train_x_data)
    test_x_data_ = pca.transform(test_x_data)

    model.fit(train_x_data_, train_y_data)
    score = model.score(test_x_data_, test_y_data)

    if score > max_score:
        max_score = score
        selected_k = n_variable

    scores.append(score)
    print('k {:3} : score {:5.2f}%'.format(n_variable, max_score * 100))

print('')
print('[MAX SCORE] : k {} : score {}%'.format(selected_k, max_score))

ks = [i for i in range(1, train_x_data.shape[1]+1)]
results = pd.DataFrame([ks,
                        scores,
                        selected_col_names]).T
results.columns = ['n_variable', 'score', 'selected_col_name']

results.to_csv(output_dir_path.joinpath('pca.tsv'),
               sep='\t',
               encoding='utf-8',
               index=False)


一章丸々次元削減に割り当てられており、実装コードも載せられているので、イメージだけでなく実感として理解をすることができます。また、データの前処理に関しても詳しく書かれており、機械学習をする上で大事なデータの準備も併せて学べることができます。




参考資料


(*1)インシテミル-wikipedia

機械学習の分類結果を可視化!決定境界

 
学習した機械学習のモデルが与えたデータに対してどのように分類したかを知りたいことは多いです。ここら先は違うクラスになるという境界がわかられば、分類モデルの理解が深まりますし、改善ポイントもわかるようになります。学生の頃に隣のクラスになろうと居座っていたことはありますが、チャイムともに戻されました。そこには明確な境界があったわけです。


今回は、学習した機械学習モデルが作る分類の境界を可視化できるようにします。その境界のことを決定境界と呼んでいます。




決定境界とは何かを見てみる


決定境界の説明をする前に実装した結果の画像をさっそく見てみます。皆さんにおなじみの「iris」のデータを使っいます。

f:id:dskomei:20180228214011p:plain
様々なアルゴリズムの決定境界


この図を見てわかる通り、決定境界とは分類クラスの境目のことです。それぞれのクラスに正しく分類しているのが良い決定境界であり、良い決定境界を作るために機械学習ではモデルをチューニングするわけです。


上の図を見てみると、決定境界と一言で言ってもアルゴリズムによって描き方が様々です。大きな特徴としては、ロジスティック回帰・決定木の境界線は直線であるのに対して、K近傍探索・SVM・ランダムフォレストは曲線です。また、ランダムフォレストは外れ値のような点にも影響され過学習習している様子が見られます。テスト勉強で丸暗記していっても、実際のテストでは全く異なる問題に対応できないタイプのようです。それぞれの特徴があって面白いですね。


決定境界を描くまでの流れ

  1. プロットを目視できるようにデータを被説明変数と説明変数2変数分のみを抽出
  2. 1.のデータを使ってモデルを学習
  3. 1.の説明変数に合わせた細かい入力データを作り、学習したモデルで分類をする
    これをプロットすることで、色別れの領域を描ける
  4. 1.のデータを3.に重ねる



決定境界を描く部分に焦点を当てて説明


見出し決定境界を描くまでの流れの3.、4.の部分を先に説明します。第一に、今回は3クラスの分類のため3種類のマーカと色を用意しています。

import numpy as np
from matplotlib.colors import ListedColormap
import matplotlib.pyplot as plt
markers = ('s', 'x', 'o')
cmap = ListedColormap(('red', 'blue', 'green'))


次に決定境界を描くためのデータを用意します。決定境界を描くためにはプロットする入力データが取り得る範囲をクラスごとに色付けをしていく必要があります。そのため、入力データを満たす領域を描けるような細かなデータを作成します。その方法がメッシュデータです。グラフを格子状に区切った際の交点のことです。この交点に高さを加えることで等高線を引くことができます。それではメッシュデータを作成してみます。与えられたデータの最小値と最大値までのメッシュデータを0.01刻みで作っています。\(x\)は2変数の入力データのデータフレームです。

x1_min, x1_max = x[:, 0].min()-1, x[:, 0].max()+1
x2_min, x2_max = x[:, 1].min()-1, x[:, 1].max()+1
x1_mesh, x2_mesh = np.meshgrid(np.arange(x1_min, x1_max, 0.01),
                                   np.arange(x2_min, x2_max, 0.01))


メッシュデータの作成において簡単に試してみます。下の例では0~5の1刻みの連番においてメッシュデータを作成しています。出力した結果を見ると0~5の1刻みの2次元マップの交点ができていることがわかります。

import numpy as np
x = np.arange(0, 5)
x1_mesh, x2_mesh = np.meshgrid(x,x)
print(x1_mesh.ravel())
print(x2_mesh.ravel())
[0 1 2 3 4 0 1 2 3 4 0 1 2 3 4 0 1 2 3 4 0 1 2 3 4]
[0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4]


メッシュデータのすべてに対して学習したモデルで分類をしています。これによりメッシュデータに分類結果の高さを加えることで、クラスごとの色分けを行うことができます。

z = model.predict(np.array([x1_mesh.ravel(), x2_mesh.ravel()]).T)
z = z.reshape(x1_mesh.shape)


matplotlibのcontourfを使って決定境界を描いています。contourfはz軸の範囲ごとに色を割り当てて描いており、contour(fをとると)にすると、色を塗らずにプロットするだけになるので決定境界線を描けます。

plt.contourf(x1_mesh, x2_mesh, z, alpha=0.4, cmap=cmap)
plt.xlim(x1_mesh.min(), x1_mesh.max())
plt.ylim(x2_mesh.min(), x2_mesh.max())


上記までで決定境界面の色付けは終わり、残りは入力データをその決定境界面にプロットするのみとなりました。各クラスごとに上で指定したマーカ/色の種類でプロットします。\(y\)は入力データに対応した教師データです。

for idx, cl in enumerate(np.unique(y)):
    plt.scatter(x=x[y == cl, 0],
                y=x[y == cl, 1],
                alpha=0.6,
                c=cmap(idx),
                edgecolors='black',
                marker=markers[idx],
                label=cl)



今回のコード



import numpy as np
from sklearn import datasets
from matplotlib.colors import ListedColormap
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
import math


#
# 決定境界プロット関数
#
def plot_decision_regions(x, y, model, resolution=0.01):

    ## 今回は被説明変数が3クラスのため散布図のマーカータイプと3種類の色を用意
    ## クラスの種類数に応じて拡張していくのが良いでしょう
    markers = ('s', 'x', 'o')
    cmap = ListedColormap(('red', 'blue', 'green'))

    ## 2変数の入力データの最小値から最大値まで引数resolutionの幅でメッシュを描く
    x1_min, x1_max = x[:, 0].min()-1, x[:, 0].max()+1
    x2_min, x2_max = x[:, 1].min()-1, x[:, 1].max()+1
    x1_mesh, x2_mesh = np.meshgrid(np.arange(x1_min, x1_max, resolution),
                                   np.arange(x2_min, x2_max, resolution))

    ## メッシュデータ全部を学習モデルで分類
    z = model.predict(np.array([x1_mesh.ravel(), x2_mesh.ravel()]).T)
    z = z.reshape(x1_mesh.shape)

    ## メッシュデータと分離クラスを使って決定境界を描いている
    plt.contourf(x1_mesh, x2_mesh, z, alpha=0.4, cmap=cmap)
    plt.xlim(x1_mesh.min(), x1_mesh.max())
    plt.ylim(x2_mesh.min(), x2_mesh.max())

    for idx, cl in enumerate(np.unique(y)):
        plt.scatter(x=x[y == cl, 0],
                    y=x[y == cl, 1],
                    alpha=0.6,
                    c=cmap(idx),
                    edgecolors='black',
                    marker=markers[idx],
                    label=cl)

#
# データの取得
#
data = datasets.load_iris()
x_data = data.data
y_data = data.target

# 2変数だけを抽出
x_data = x_data[:, [0,1]]

# 入力データの各変数が平均0,標準偏差1になるように正規化
# 各アルゴリズムのプロット結果を比較しやすいように予め全入力データを正規化
sc = StandardScaler()
sc.fit(x_data)
x_data = sc.transform(x_data)


# データを学習用/テスト用に分割している
x_train, x_test, y_train, y_test = train_test_split(x_data,
                                                    y_data,
                                                    test_size=0.2)

#
# 機械学習アルゴリズムの定義
#
lr = LogisticRegression(C=10)
knn = KNeighborsClassifier(n_neighbors=5)
svm = SVC(kernel='rbf', C=1.0)
dc = DecisionTreeClassifier(criterion='entropy', max_depth=3)
rf = RandomForestClassifier(criterion='entropy',
                            n_estimators=10)

models = [lr, knn, svm, dc, rf]
model_names = ['logistic regression',
               'k nearest neighbor',
               'svm',
               'decision tree',
               'random forest']

#
# それぞれのモデルにおいて決定境界をプロット
#
plt.figure(figsize=(8,6))
plot_num = 1
for model_name, model in zip(model_names, models):

    plt.subplot(math.ceil(len(models)/2), 2, plot_num)
    # モデルの学習
    model.fit(x_train, y_train)
    # 決定境界をプロット
    plot_decision_regions(x_data, y_data, model)
    plt.title(model_name)
    plot_num += 1

plt.tight_layout()
plt.savefig('./images/decision_region.png')
plt.show()



決定境界に関して様々なアルゴリズムで記載されています。更にデータの前処理からkerasを使ってのディープラーニングの作り方まで、この本を読めば機械学習を一通り抑えられます。




終わりに


説明変数が2変数より多い場合には、説明変数を2変数に絞るにあたってどれを選べばいいのかは悩ましい問題です。飲み会で誰の隣になると楽しいかを見極めるのと同じくらい難しいです。そんな感じです。それはさておき、変数選択の方法は色々あるので、勉強しながら今後まとめていきたいと思います。

Pythonで機械学習をやってみる!複数回試行での評価

前回以下のエントリを書きました。そのエントリでは複数の機械学習のアルゴリズムの正答率を比較しましたが、1回の試行だけだったので複数回試行の結果でアルゴリズムを評価したいと思います。(*前回行った学習を複数回に拡張しただけです。)
dskomei.hatenablog.com


機械学習アルゴリズムの評価方法では有名なもの交差検証などがありますが、別の機会にアルゴリズムも踏まえて記載したいと思います。
本エントリではirisのデータに対して100回の試行による正答率の平均値と標準偏差で比較しています。実行結果は以下の表のとおりです。






学習用データ

テスト用データ

モデル名

平均値

標準偏差

平均値

標準偏差

decision tree

98%

0.01

94%

0.04

k nearest neighbor

96%

0.01

95%

0.04

logistic regression

96%

0.01

93%

0.04

random forest

100%

0.01

95%

0.04

svm

98%

0.01

96%

0.03


それぞれのモデルともテスト用データの正答率は、学習用データの正答率から3~4ポイントほど落ちています。ランダムフォレストは学習用データに対しては正答率が高いですが、テスト用データとの差分は一番大きいです。若干過学習しているかと思われます。SVMは学習用データとテスト用データとの差分が小さく、正答率も悪くないので今回においてはいいモデルのようです。




複数回試行の正答率を可視化する


今回の100回の試行において、学習用データ/テスト用データの正答率を分けてすべてプロットしました。
どのモデルも学習用データの方が正答率が高いのがひと目で分かりますし、ランダムフォレストが過学習気味なのもわかります。


f:id:dskomei:20180221003651p:plain


今回のコード


モデルの正答率集計の複数回試行に加えて、可視化のコードも載せています。グラフ用のライブラリとしては『seaborn』を使っています。簡単に使えてmatplotlibでは描けないグラフもできるのでぜひお試しください。
qiita.com

import pandas as pd
import numpy as np
from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline
import matplotlib.pyplot as plt
import seaborn as sns

#
# データ部
# データを読み込んだ後、入力・出力データに分けている
#
data = datasets.load_iris()
x_data = data.data
y_data = data.target


#
# モデル設定部
#
lr = Pipeline([('scl', StandardScaler()),
               ('clf', LogisticRegression(C=10))])

knn = Pipeline([('scl', StandardScaler()),
                ('clf', KNeighborsClassifier(n_neighbors=5))])

svm = Pipeline([('scl', StandardScaler()),
                ('clf', SVC(kernel='rbf', C=1.0))])

dc = DecisionTreeClassifier(criterion='entropy', max_depth=3)


rf = RandomForestClassifier(criterion='entropy',
                            n_estimators=10)

models = [lr, knn, svm, dc, rf]
model_names = ['logistic regression',
               'k nearest neighbor',
               'svm',
               'decision tree',
               'random forest']


## 複数回の試行によりモデルを評価する
score_datas = pd.DataFrame()
for loop in range(100):
    x_train, x_test, y_train, y_test = train_test_split(x_data,
                                                        y_data,
                                                        test_size=0.2)

    for model_name, model in zip(model_names, models):
        model.fit(x_train, y_train)
        train_score = model.score(x_train, y_train)
        test_score = model.score(x_test, y_test)

        print('loop {}, Model : {:20}, train accuracy : {:3.0f}, test accuracy : {:3.0f}'.format(loop,
                                                                                                 model_name,
                                                                                                 train_score * 100,
                                                                                                 test_score * 100))

        score_datas = pd.concat([score_datas,
                                 pd.DataFrame({'number':[loop],
                                               'model_name':[model_name],
                                               'score_name':['train'],
                                               'score':[train_score]}),
                                 pd.DataFrame({'number': [loop],
                                               'model_name': [model_name],
                                               'score_name': ['test'],
                                               'score': [test_score]})])
score_datas_sum = score_datas.groupby(['score_name', 'model_name']).agg({'score':[np.mean, np.std]})
print(score_datas_sum)

fig = sns.factorplot(x='model_name',
                     y='score',
                     col='score_name',
                     data=score_datas,
                     kind='swarm')
fig.set_xlabels('')
fig.set_xticklabels(rotation=40)
plt.tight_layout()
plt.savefig('./images/scores.png', dpi=300)
plt.show()



終わりに


今回は複数回試行によるモデルの評価を行いましたが、ハイパーパラメータの値によって結果が異なってきます。なので、ハイパーパラメータの調整を先に行った上でモデル間の比較を行う必要があります。次回はハイパーパラメータのチューニングによる結果の違いを見ていきたいと思います。

Pythonで機械学習をやってみる!

本エントリはとにかく機械学習をやってみたいという思いだけで突っ走って書きました。機械学習をしてドヤりたい人、色々アルゴリズムがあるのは知っているけど実際どうやるんだっけという人向けになっていると思います。理論より実践!!という感じなので玄人の方々ご容赦ください。(理論的な部分は今後一つ一つ追っていくつもりです。たぶん。)


これを読んだらどうなるの?

  • とりあえず機械学習ができる
  • いろんなアルゴリズムを試せる
  • こんな感じでやってしまっていいのって疑問を持つ




機械学習って何ぞや?


なぜ機械学習がこんなに世間を賑わせているかは、機械学習によって自動化できる範囲が広がるからだと思います。電脳戦や画像分類のようにすでに自動化は人間たらしめる脳処理の部分まで行われています。機械学習は、人間がしていることを代替し、場合によっては人間よりも精度が高く、休まないというおまけ付きなんですね。機械学習を使うと優秀な新卒が入ってきたらすべてを任せて最後だけ顔を出す上司的なことができてしまうんですね。


人間の行動は、インプット⇒ 脳処理 ⇒ アウトプットという流れが基本です。例えば、道端できれいな女性を見かけたら(インプット)、過去の記憶から判断し(脳処理)、硬直したまま立ち止まる(アウトプット?)的な感じですね。これは間違った脳処理をしてしまっていますね(まぁ、何が正解はわからないですが)。話を元に戻すと、これはインプット\(x\) ⇒ 変換\(f(・)\) ⇒ アウトプット\(y\)の形になります。まさしく\(y=f(x)\)なわけです。


機械で\(y=f(x)\)の\(f(・)\)を作ってしまえば、人間と同じような処理ができ、\(f(・)\)の精度を上げていけば人間の処理以上のことができる、だから\(f(・)\)のパラメータを調整(学習)させようというのが機械学習なんです。


機械学習の流れ

  1. データ部(用意して、加工)
  2. 学習部
  3. 測定部


 大まかな流れは上記の通りです。ただ実際には、データの前処理に時間がかかり、システムに組み込むまで必要になることもありますが、一旦見て見ぬふりをします。


Pythonで機械学習


とにかく実践してみます。今回はPythonのライブラリとして『sklearn』を使っています。sklearnはpipを使えば簡単にインストールできるので、まだ入れていない方はぜひ。データは機械学習の導入としてよく使われる『iris』です。これもすでに数多くWeb上に文献があるので、詳細を知りたい方は検索してみて下さい。

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

#
# データ部
# データを読み込んだ後、入力・出力データに分けている
#
data = datasets.load_iris()
x_data = data.data
y_data = data.target

# データを学習用/テスト用に分割している
# 2割がテスト用
x_train, x_test, y_train, y_test = train_test_split(x_data,
                                                    y_data,
                                                    test_size=0.2)
#
# 学習部
# 今回は学習器として深さ3の『決定木』を使っている
#
model = DecisionTreeClassifier(criterion='entropy', max_depth=3)
model.fit(x_train, y_train)

#
# 測定部
# 正答率を測定している
#
train_score = model.score(x_train, y_train)
test_score = model.score(x_test, y_test)
print('train accuracy score : {:.0f}%'.format(train_score*100))
print('test accuracy score : {:.0f}%'.format(test_score*100))


決定木による学習結果は以下のようになりました。学習用データとテスト用データのそれぞれに対して正答率を出しています。

train accuracy score : 98%
test accuracy score : 97%


決定木による学習はできたのでひとまず機械学習をやりました。ついでなので、一気に色んなアルゴリズムを試してみましょう。以下は、色々な機械学習のアルゴリズムを試した結果です。

Model : logistic regression , train accuracy :  95, test accuracy :  93
Model : k nearest neighbor  , train accuracy :  97, test accuracy :  97
Model : svm                 , train accuracy :  98, test accuracy :  97
Model : decision tree       , train accuracy :  98, test accuracy :  97
Model : random forest       , train accuracy : 100, test accuracy :  97


上からロジスティック回帰、K近傍探索、SVM、決定木、ランダムフォレストに関して正答率を測定しました。上の結果を見ると、ランダムフォレストが正答率高いようですが、過学習をしているようにも見えます。ただデータ数が少なく、試行回数が1回なのでなんとも言えないです。コードは以下です。

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline


#
# データの読み込み
#
data = datasets.load_iris()
x_data = data.data
y_data = data.target

# データを学習用/テスト用に分割している
x_train, x_test, y_train, y_test = train_test_split(x_data,
                                                    y_data,
                                                    test_size=0.2)
#
# 学習データをモデルに投入する前に、各変数において、平均0、標準偏差が1になるように
# 正規化するため、モデルの定義の前に正規化処理を入れている
#
lr = Pipeline([('scl', StandardScaler()),
               ('clf', LogisticRegression(C=10))])

knn = Pipeline([('scl', StandardScaler()),
                ('clf', KNeighborsClassifier(n_neighbors=5))])

svm = Pipeline([('scl', StandardScaler()),
                ('clf', SVC(kernel='rbf', C=1.0))])

##  決定木とランダムフォレストは学習データの正規化をしなくても良い
dc = DecisionTreeClassifier(criterion='entropy', max_depth=3)

rf = RandomForestClassifier(criterion='entropy',
                            n_estimators=10)

models = [lr, knn, svm, dc, rf]
model_names = ['logistic regression',
               'k nearest neighbor',
               'svm',
               'decision tree',
               'random forest']

#
# 指定したモデルをぞれぞれ学習させている
#
for model_name, model in zip(model_names, models):

    model.fit(x_train, y_train)
    train_score = model.score(x_train, y_train)
    test_score = model.score(x_test, y_test)

    print('Model : {:20}, train accuracy : {:3.0f}, test accuracy : {:3.0f}'.format(model_name,
                                                                                    train_score*100,
                                                                                    test_score*100))


実装部分はこちらの本を参考にしております。一通りの機械学習のアルゴリズムを実装メインで書かれているので、自分で手を動かしながら学べるので、理解が早いです。



終わりに


これで機械学習において一連の流れができました。ただ実際は、機械学習の工程①、②、③のそれぞれに対して試すことが多々あり、時間がかかります。その点に関しては今後それぞれの理論も踏まえてかけたらなあと思います。


今回は、機械学習をやる上での流れは抑えられたかと思います。家を作る際の骨格は作れたかと。後は、部屋割り、壁紙、インテリアなど細部をどんどん詰めていく事が大事です。しかし。何もしなければコントで使われる舞台セット的な感じですかね。


この記事で誰かのモチベーションを少しでも高められたら幸いです。


おまけ