データサイエンティストによる統計入門 ― k平均法でデータをクラスタリングしてみよう!
ビッグデータ、データサイエンス、人工知能など、統計学を主軸においた分野が隆盛ですが、統計学には高いハードルを感じる方も少なくないでしょう。k平均法を実際に手を動かしながら理解することで、データ分析を身近に感じることができます。
はじめまして、藤井健人(@studies)と申します。イタンジ株式会社でデータ基盤周りの運用を担当しています。
「ビッグデータ」「データサイエンス」「人工知能」といったバズワードに代表されるように、統計学を主軸においた分野の隆盛が日常となって久しいです。
しかし「統計学は学問的な要素があり難しい」という印象を持たれやすく、「実務に活かすのはハードルが高い、怖い」と感じる方も少なくないのではないでしょうか。
そういった方を対象に、今回は統計学の手法の一つであるk平均法を学んでいただきたく筆をとりました。
Webエンジニアが統計を学ぶことで、以下のようなメリットが得られると筆者は考えます。
- エンジニアは、他職種と比較してデータに近いポジションであるため、気軽にデータを取得して、触り、仮説を検証することができる
- 統計学を駆使することで、より確度の高い開発方向を決める指針が得られる
- 機械学習といった、統計学を応用した技術の基礎理解に役立つ
本稿は次の3部からなり、ソースコードをもとに手を動かしながら理解を深められる構成となっています。
- 理論の概要を学ぶ
- ゼロからロジックを実装する
- ライブラリを扱ってみる
本稿を通じてテータ分析、ひいては統計学をより身近に感じていただき、実務への心理的な壁を壊す手助けになれば幸いです。
PythonとJupyter Notebookの環境を用意する
分析を始める前に、分析を行う環境を準備しましょう。本稿で動作確認を行ったソフトウェア(Pythonとそのライブラリ)は次の通りです。
ソフトウェア | 説明 |
---|---|
Python 3.5.1 | プログラミング言語 |
jupyter 1.0.0 | データ分析用の実行環境(Jupyter Notebook) |
pandas 0.22.0 | データ解析を支援するライブラリ(Pandas) |
numpy 1.14.0 | 数値計算用ライブラリ(NumPy) |
scikit-learn 0.19.1 | 機械学習ライブラリ |
matplotlib 2.1.0 | グラフ描画ライブラリ(Matplotlib) |
今回は、データ分析の記録に最適な実行環境Jupyter Notebookを利用して学習を進めていきます。
まずはプロジェクトのディレクトリを作ります。
$ mkdir k_means $ cd k_means
次に、各種ライブラリをインストールするためのファイルrequirements.txt
を該当ディレクトリに置きます。
jupyter==1.0.0 pandas==0.22.0 numpy==1.14.0 scikit-learn==0.19.1 matplotlib==2.1.0
最後にPythonの仮想環境を作った上で、必要なライブラリをインストールして、Jupyter Notebookを起動しましょう。
$ python -m venv env $ source env/bin/activate $ pip install -r requirements.txt $ jupyter notebook
最後のコマンドでブラウザが起動し、Jupyter Notebookが表示されれば準備OKです。
k平均法によるクラスタリングの概要を理解する
k平均法は、データクラスタリングと言われる分野の手法の一つです。
データクラスタリングとは、データを外的基準なしに似たもの同士をくっつけてクラスター(群れ)に分けてしまうことです。教師なし機械学習に分類され、データマイニングにもよく使われる手法です。
実務では、例えばイタンジの場合、賃貸住宅をレコメンドするシーンで利用しています。賃料や敷金といった住宅情報から賃貸住宅をクラスターにまとめ、類似物件としてユーザーに提案する用途です。
5つの座標データ
ここで、k平均法の基本的な考え方を説明します。
k平均法は、データごとの類似点の中心を見つけ出し、データを意味のあるクラスターに分ける手法です。
例えば、手元に次のような5つの座標データがあるものとして、これらを2つのクラスターに分けたいとします。
(1, 1), (1, 2), (2, 2), (4, 5), (5, 4)
Jupyter Notebookで新しいNotebookを作り、次のコードを入力します。
%matplotlib inline import numpy as np import pandas as pd import matplotlib.pyplot as plt samples = np.array([ [1, 1], [1, 2], [2, 2], [4, 5], [5, 4]]) df_sa = pd.DataFrame(samples) plt.grid() plt.scatter(df_sa[0], df_sa[1], c='blue') plt.show()
実行すると、次の図がプロットされます。
2つのクラスターそれぞれの中心を適当に決め打ちする
ここで2つのクラスターの中心を次のように、適当に決め打ちしてあげましょう。
(1, 1.1), (1, 2.1)
Notebookに次のように入力します。
centers = np.array([ [1, 1.1], [1, 2.1]]) df_ce = pd.DataFrame(centers) plt.grid() plt.scatter(df_sa[0], df_sa[1], c='blue') plt.scatter(df_ce[0], df_ce[1], c='red') plt.show()
次の図がプロットされます。
中心に近い座標をクラスターに分ける
次に、それぞれの座標(samplesデータ)がどちらの中心(centersデータ)に距離が近いかを計算して、クラスターに分けます。距離を測る計算式はこちら。
試しにsamples(1, 1)の、centers(1, 1.1)とcenters(1, 2.1)に対するそれぞれの距離を具体的に計算してみると
となります、よってsamples(1.1)はcenters(1, 1.1)に近いと言えます。
他のsamplesとcentersの距離計算は省きますが、この例であればsamples(1, 1)以外の4つのデータはすべてcenters(1, 2.1)の方が近そうです。
よってクラスター分けの結果は次のようになりそうです。
(1, 1.1) | (1, 2.1) |
---|---|
(1, 1) | (1, 2) |
(2, 2) | |
(4, 5) | |
(5, 4) |
クラスター分けの結果から中心の値を更新する
そして、ここからクラスターの中心(centers)の値を更新します。
centersの更新後の値は、先ほど分けたクラスターごとのサンプルの平均を取ると得られます。
centers(1, 1.1)のクラスターはsamples(1, 1)しか属してないのでそのままですが、centers(1, 2.1)のクラスターに対しては平均を計算して値を更新してあげましょう。
新たなクラスターの中心のx軸の値は
新たなクラスターの中心のy軸の値は
この計算を経て、更新した後のcentersは次の値になります。
(1, 1.1), (3, 3.25)
Notebookに次のコードを入力します。
centers = np.array([ [1, 1.1], [3, 3.25]]) df_ce = pd.DataFrame(centers) plt.grid() plt.scatter(df_sa[0], df_sa[1], c='blue') plt.scatter(df_ce[0], df_ce[1], c='red') plt.show()
次の図がプロットされます。
更新を繰り返すと最終的に中心が安定する
このようにcentersの値を更新する一連の処理を何度か繰り返します。
更新を繰り返すとcentersの値は最終的に(4/3, 5/3), (4.5, 4.5)となり、centersの値が安定し動かなくなります。
centers = np.array([ [4/3, 5/3], [4.5, 4.5]]) df_ce = pd.DataFrame(centers) plt.grid() plt.scatter(df_sa[0], df_sa[1], c='blue') plt.scatter(df_ce[0], df_ce[1], c='red') plt.show()
プロットした図です。
クラスター分けの結果
よって、クラスタリングによるクラスター分けは次の値に落ち着き、
(4/3, 5/3) | (4.5, 4.5) |
---|---|
(1, 1) | (5, 4) |
(1, 2) | (4, 5) |
(2, 2) |
分類の結果は次のようになります。
クラスターA | クラスターB |
---|---|
(1, 1) | (5, 4) |
(1, 2) | (4, 5) |
(2, 2) |
次のコードで、
cluster_a = np.array([ [1, 1], [1, 2], [2, 2]]) cluster_b = np.array([ [5, 4], [4, 5]]) df_a = pd.DataFrame(cluster_a) df_b = pd.DataFrame(cluster_b) plt.scatter(df_a[0], df_a[1], c='orange') plt.scatter(df_b[0], df_b[1], c='lightgreen') plt.grid() plt.show()
次のようにプロットできます。
k平均法のロジックを作ってみよう
それでは、前のセクションでは手計算したk平均法のロジックを、自前で実装してみましょう。
k平均法のロジックを内包するクラスとしてMyKMeans
クラスを用意します。MyKMeans
クラスの初期化では、次を指定します。
- 欲しいクラスターの数
- クラスターの中心を更新する回数
- 乱数を制御するパラメータ
乱数の制御にはNumPyを使用します。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state)
クラスターの中心の初期値を決める
まず、クラスターの中心の初期値を決めましょう。
下記はNumPyのrandom.permutation
を利用して、クラスターの中心の座標を初期値サンプルデータからランダムに選び取る処理です。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state) def fit(self, X): initial = np.random.permutation(X.shape[0])[:self.n_clusters] self.cluster_centers_ = X[initial] return self
より詳細に説明すると、次の処理を記述してます。
- サンプルデータのインデックスの数の範囲に収まるランダムな整数を、サンプルデータの数だけ生成する
- 生成したランダムな整数の配列をクラスターの数に絞る
- クラスターの数に絞られたランダムな整数配列を使って、クラスターの中心の座標の初期値サンプルデータから選び取る
実装した処理が正しいかどうかを、次のコードでテストしましょう。
X = np.array([[1,1],[1,2],[2,2],[4,5],[5,4]]) kmeans = MyKMeans(n_clusters=2, max_iter=5, random_state=1).fit(X) assert(kmeans.cluster_centers_.shape == (2, 2)) assert(all(c in X for c in kmeans.cluster_centers_)) assert(not np.array_equal(kmeans.cluster_centers_[0], kmeans.cluster_centers_[1]))
このテストコードでは、次のことを確認しています。
- 生成されたクラスターの中心が2×2の配列になっているか
- 生成されたクラスターの中心の初期値がサンプルデータの中に含まれているか
- クラスターの中心の初期値同士は座標がかぶらない
クラスター分けの処理
さて、これから実際にクラスター分けをする処理を記述していきます。
先ほどk平均法によるクラスタリングの概要で説明したように、クラスター分けの処理は次の2つに大別されます。
- クラスターの中心とサンプルデータごとの距離を計算する
- どのクラスターの中心が一番距離が近いかを判定する
各サンプルデータとクラスターの中心との距離を計算する処理を書いてみましょう。
距離の計算ロジックは下記です。
この式を_distance
メソッドに落とし込みます。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state) def fit(self, X): initial = np.random.permutation(X.shape[0])[:self.n_clusters] self.cluster_centers_ = X[initial] return self def _distance(self, centers, x): return np.sqrt(((x - centers)**2).sum(axis=1))
それでは_distance
メソッドが正しく実装されたか、次のコードでテストしてみましょう。
kmeans = MyKMeans(n_clusters=2, max_iter=5, random_state=1) distance = kmeans._distance(np.array([[1, -1],[2, 0]]), np.array([3, 3])) assert(np.allclose(distance, np.array([4.47213595, 3.16227766])))
このテストコードは、次のように仮定して、
クラスターの中心: (3, 3) サンプルデータ: (1, -1), (2, 0)
_distance
メソッドの計算結果が正しいかを検証しています。
クラスターの中心からの距離を判定する
どのクラスターの中心が一番近いかを判定する処理も定義しましょう。
NumPyのargmin
を使うことで楽に実現できます。
ここで定義する_nearest
メソッドは最も近いサンプルデータのインデックスを返します。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state) def _nearest(self, centers, x): return np.argmin(self._distance(centers, x)) def _distance(self, centers, x): return np.sqrt(((x - centers)**2).sum(axis=1))
_nearest
メソッドのテストを書きましょう。
kmeans = MyKMeans(n_clusters=2, max_iter=5, random_state=1) nearest = kmeans._nearest(np.array([[1, 2],[2, 2]]), np.array([4,5])) assert(nearest == 1)
このテストコードは次のように仮定して、
クラスターの中心: (4, 5) サンプルデータ: (1, 2), (2, 2)
_nearest
メソッドが最も距離が短いサンプルデータ(2, 2)のインデックスを返すことを検証しています。
処理の追加
それではクラスター分けを行う処理を追加していきましょう。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state) def fit(self, X): initial = np.random.permutation(X.shape[0])[:self.n_clusters] self.cluster_centers_ = X[initial] self.labels_ = np.array([self._nearest(self.cluster_centers_, x) for x in X]) X_by_cluster = [X[np.where(self.labels_ == i)] for i in range(self.n_clusters)] return X_by_cluster def _nearest(self, centers, x): return np.argmin(self._distance(centers, x)) def _distance(self, centers, x): return np.sqrt(((x - centers)**2).sum(axis=1))
上記では、次の処理をfit
メソッド内に追加しています。
- サンプルデータごとに一番距離が短いクラスターの中心を判定
- サンプルデータをそれぞれクラスターにグループ分けして多次元配列として格納する
クラスター分けが正しく行われたかを検証するコードは下記の通りです。
X = np.array([[1, 1],[1, 2],[2, 2],[4, 5],[5, 4]]) kmeans = MyKMeans(n_clusters=2, max_iter=5, random_state=1) X_by_cluster = kmeans.fit(X) assert(np.array_equal(X_by_cluster[0], X[2:])) assert(np.array_equal(X_by_cluster[1], X[:2]))
次のサンプルデータが
(1, 1), (1, 2), (2, 2), (4, 5), (5, 4)
それぞれ次のようにクラスタリングされていることをテストしています。
クラスターA | クラスターB |
---|---|
(1, 1) | (2, 2) |
(1, 2) | (5, 4) |
(4, 5) |
クラスターの中心座標を更新
最後に、MyKMeans
クラスの初期化時に設定した更新回数分だけ、クラスターの中心を更新する処理を書きましょう。
更新された際のクラスターの中心座標は、クラスター分けされたサンプルの平均を取ると得られます。
fit
メソッドに処理を追加します。
class MyKMeans(object): def __init__(self, n_clusters=8, max_iter=300, random_state=None): self.n_clusters = n_clusters self.max_iter = max_iter self.random_state = random_state if self.random_state: np.random.seed(self.random_state) def fit(self, X): initial = np.random.permutation(X.shape[0])[:self.n_clusters] self.cluster_centers_ = X[initial] for _ in range(self.max_iter): self.labels_ = np.array([self._nearest(self.cluster_centers_, x) for x in X]) X_by_cluster = [X[np.where(self.labels_ == i)] for i in range(self.n_clusters)] self.cluster_centers_ = [c.sum(axis=0) / len(c) for c in X_by_cluster] return self def _nearest(self, centers, x): return np.argmin(self._distance(centers, x)) def _distance(self, centers, x): return np.sqrt(((centers - x)**2).sum(axis=1))
検証する
それでは仕上げのテストです、クラスターの中心の繰り返し更新の結果が正しいか検証してみましょう。
前章で既出のクラスタリングの結果が下記の通りです。
(4/3, 5/3) | (4.5, 4.5) |
---|---|
(1, 1) | (5, 4) |
(1, 2) | (4, 5) |
(2, 2) |
下記のコードで、MyKMeans
が処理した結果が一致するか検証し、
X = np.array([[1,1],[1,2],[2,2],[4,5],[5,4]]) kmeans = MyKMeans(n_clusters=2, max_iter=10, random_state=1).fit(X) assert(np.allclose(np.array(kmeans.cluster_centers_), np.array([[4.5, 4.5],[4/3,5/3]]))) centers = np.array(kmeans.cluster_centers_) df_sa = pd.DataFrame(X) df_ce = pd.DataFrame(centers) plt.grid() plt.scatter(df_sa[0], df_sa[1], c='blue') plt.scatter(df_ce[0], df_ce[1], c='red') plt.show()
グラフにプロットします。
k平均法のライブラリを試してみる
前章で実際に実装してみたことで、k平均法のロジックが把握できたかと思います。
それでは、ライブラリを駆使してk平均法を実行してみましょう。ライブラリはscikit-learnを使用します。
MyKMeansではなくscikit-learnのKMeansを使う
サンプルデータには先ほどと同じ下記を使いましょう。
(1, 1), (1, 2), (2, 2), (4, 5), (5, 4)
コードは次のように、シンプルになります。
from sklearn.cluster import KMeans X = np.array([[1,1],[1,2],[2,2],[4,5],[5,4]]) km = KMeans(n_clusters=2, init='random', max_iter=300, random_state=0) y_km = km.fit_predict(X)
上記は、前章の初期化時の実装と同じく次を指定しています。
変数 | 説明 |
---|---|
n_cluster | 欲しいクラスターの数 |
max_iter | クラスターの中心を更新する回数 |
random_state | 乱数を制御するパラメータ |
init
というキーワード引数では「クラスターの中心の初期値の座標はサンプルデータの中からランダムに選ぶ」ことを宣言しています。
ではクラスタリングした結果をプロットしてみましょう。
plt.scatter(X[y_km == 0, 0], X[y_km == 0, 1], c='lightgreen', label='cluster 1') plt.scatter(X[y_km == 1, 0], X[y_km == 1, 1], c='orange', label='cluster 2') plt.grid() plt.legend(loc="upper left") plt.show()
次のようになります
scikit-learnにあるアヤメの花のデータを試す
他のデータのクラスタリングも試してみましょう。
scikit-learnには有名なアヤメの花のデータセットも用意されています。
sepal length (cm) | petal length (cm) | |
---|---|---|
0 | 5.1 | 1.4 |
1 | 4.9 | 1.4 |
2 | 4.7 | 1.3 |
3 | 4.6 | 1.5 |
4 | 5.0 | 1.4 |
この「アヤメの花のがく片の長さと花びらの長さ」をそれぞれサンプルデータとしましょう。
from sklearn import datasets iris = datasets.load_iris() df = pd.DataFrame(iris.data, columns=iris.feature_names) X = df[['sepal length (cm)', 'petal length (cm)']] X.head()
クラスタリングを行う前にデータを図にプロットして眺めてみましょう。
plt.scatter(X['sepal length (cm)'], X['petal length (cm)'], c='orange') plt.show()
グラフを眺めるとなんとなく3つのクラスターに分けることができる気がします。
アヤメの花のクラスタリング
それではクラスタリングをしていきましょう。
分けるクラスターの数は3つです。
km = KMeans(n_clusters=3, init='random', max_iter=300, random_state=0) y_km = km.fit_predict(X) y_km
クラスタリングの結果が0から2のラベルで出力されます。
array([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1], dtype=int32)
出力されたラベルに沿って、色別に分けてサンプルデータをプロットします。
plt.scatter(X[y_km == 0]['sepal length (cm)'], X[y_km == 0]['petal length (cm)'], c='green', label='cluster_0') plt.scatter(X[y_km == 1]['sepal length (cm)'], X[y_km == 1]['petal length (cm)'], c='yellow', label='cluster_1') plt.scatter(X[y_km == 2]['sepal length (cm)'], X[y_km == 2]['petal length (cm)'], c='red', label='cluster_2') plt.grid() plt.legend(loc="upper left") plt.show()
次のようになります。
アヤメの花の答え合わせ
それでは答え合わせをしましょう。
アヤメの花の品種は下記で取得できます。
iris.target_names
array(['setosa', 'versicolor', 'virginica'], dtype='<U10')
サンプルデータがそれぞれどの品種に該当するかは下記で取得できます。
y = iris.target.flatten() y
上記の品種の配列のインデックスと対応しており
0: setosa 1: versicolor 2: virginica
出力は次のようになります。
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
正解の図をプロットしてみましょう。
plt.scatter(X[y == 0]['sepal length (cm)'], X[y == 0]['petal length (cm)'], c='red', label='setosa') plt.scatter(X[y == 1]['sepal length (cm)'], X[y == 1]['petal length (cm)'], c='yellow', label='versicolor') plt.scatter(X[y == 2]['sepal length (cm)'], X[y == 2]['petal length (cm)'], c='green', label='virginica') plt.grid() plt.legend(loc="upper left") plt.show()
正解図の傾向が、クラスタリングの結果と一致していることが確認できました。
まとめ
本稿では、k平均法を介して統計学に触れてみました。どうでしょう? 理解の実感が得られたならば幸いです。
また、Pythonでは本稿で使用したJupyter Notebook、Pandas、NumPy、scikit-learn,Matplotlibのほかにも、SciPy(科学計算用ライブラリ)やSymPy(代数計算ライブラリ)といったエンジニア向けのツール群も揃っており、統計学を実務に活かす敷居はどんどん下がっている印象です。
参考文献
本稿は、弊社の機械学習のトレーニングプログラムを、より詳細に開設したものです。このトレーニングプログラムには、k平均法以外にもさまざまな手法の解説が載っています。よかったら試してみてください。
また本稿を作成するにあたり、Sebastian Raschka著『Python機械学習プログラミング』(インプレス、2016年)を参考にしました。たいへん良い本でお薦めです。
執筆者プロフィール
藤井 健人(ふじい・けんと) @studies)
編集:薄井千春(ZINE)