27.機械学習でデータをグループ分けしよう
――さらに試行せよ。何が可能かを知るために。――
マイケル・ファラデー
機械学習でデータをグループ分けしよう
もうすぐ4月も終わり、私の留学も残り2ヵ月となりました。この国に来た頃は生活の立ち上げや研究計画のことなど、諸々の不安で夜も眠れなかったなあ、と懐かしく思い返します。
本業の研究の方は、別のトピックでプログラムを組み終わったので、これでしばらく実験します。できればもう一本論文を書きたいです。この一年で学部卒くらいのレベルにはなれたかな、という気がします(たったそれだけ?)。
ところで、以前の記事で、混合ガウスモデル(GMM:Gaussian mixture model)という確率論ベースの教師無し機械学習モデルを使って、データをグループ分け(Clustering)する方法をご紹介しました。
GMMは、確率論の導入として情報系の学生が必ず一度は自力で実装するアルゴリズムではありますが、データのグループ分けに使うアルゴリズムには、もっと初歩的な「K-means」や「Fuzzy c-means」と呼ばれる手法があります。
電磁誘導の法則で有名なファラデーが言うように、どんな手法も試行しなければ何ができるのかは分かりません。今回の記事は、これらの手法を現実のデータに適用し、実際にグループ分けを行ってみたいと思います。
K-meansアルゴリズム
K-meansアルゴリズムは、教師無し機械学習の分野では最も有名な、ベーシックかつ高速で使い勝手の良いアルゴリズムです。入力した複数のデータ点をk個の近接グループに分割(Hard clustering)します。
基本的にはグループの重心(Centroid)を計算するRefinement stepと、グループの番号(Label)を割り当てるAssignment stepとを何度も繰り返して収束させる、反復型のアルゴリズムです。
今、次元の特徴量ベクトル(項目が個あるデータ)からなる入力データをいくつも用意し、これらを個のグループに分けたいとします。
このとき、重心は以下のように計算されます。
数式で書くとなんだか仰々しいですね。この式は単に番目のグループに属するデータに限定して平均をとっているだけです。
続いて、ある入力データに対して適切なグループ番号を割り当てるには、入力データと得られた各重心との絶対距離(L2ノルム)について、最小値探索を行います。
以上のステップを交互に繰り返し、以下の損失関数が変化しなくなったら収束したものとみなして終了します。
K-meansをPythonで実装
以上のとおり、K-meansは直観的にも理解しやすいシンプルなアルゴリズムなので、numpyライブラリ等を使えばPythonで簡単に実装することができます。
import sys import time import numpy as np from scipy import linalg #Function for checking empty clusters def check_empty(label, num_clu): #Get unique label (which must include all number 0~num_clu-1) label = np.unique(label) #Search empty clusters emp_ind = [] for i in range(num_clu): if i not in label: emp_ind.append(i) #Output the indices corresponding to the empty clusters return emp_ind #Function for getting k-means clustering def get_KMeans(X, num_clu, max_iter, num_init): #Define the length of input N = int(X.shape[0]) #The number of data p = int(X.shape[1]) #The length of feature axis #For a progress bar unit = int(np.floor(num_init/10)) bar = "#" + " " * int(np.floor(num_init/unit)) start = time.time() #Repeat for each trial (initialization) minloss = np.inf for init in range(num_init): #Display a progress bar print("\rk-means:[{0}] {1}/{2} Processing..".format(bar, init, num_init), end="") if init % unit == 0: bar = "#" * int(np.ceil(init/unit)) + " " * int(np.floor((num_init-init)/unit)) print("\rk-means:[{0}] {1}/{2} Processing..".format(bar, init, num_init), end="") #Initialize label and centroid as random numbers label = np.round((num_clu-1) * np.random.rand(N)) center = np.random.rand(num_clu, p) loss = np.inf #Repeat for each iteration for rep in range(max_iter): #Reset loss value new_loss = 0 ### Refinement step (update center) ### for j in range(num_clu): #You can use the following code for concise implementation #center[j, :] = X[label==j].mean(axis=0) #Construct data matrix for the j-th cluster clu_X = [] for i in range(N): #If the i-th data belongs to the j-th cluster if label[i] == j: clu_X.append(X[i, :]) clu_X = np.array(clu_X) #Update the j-th centroid center[j, :] = np.mean(clu_X, axis=0) ### Assignment step (update label) ### #Initialize valuable for new label vector new_label = np.zeros(N) for i in range(N): #You can use the following code for concise implementation #dist = np.linalg.norm(X[i, :]-center, axis=1) #j = int(np.argsort(dist)[0]) #new_label[i] = j #new_loss = new_loss + dist[j] #Define the minimum distance mindist = np.inf #Search the closest centroid for the i-th data for j in range(num_clu): #Compute the norm (equivalent to sqrt(sum**2)) dist = np.linalg.norm(X[i, :]-center[j, :]) #Assign the label corresponding to the minimum distance if dist < mindist: #Update minimum distance mindist = dist new_label[i] = j #Get summation of the minimum distances new_loss = new_loss + mindist #Call my function for checking empty clusters emp_ind = check_empty(new_label, num_clu) if len(emp_ind) > 0: for ind in emp_ind: #Assign the same index of data as the one of cluster new_label[ind] = ind #Get out of the loop if loss and label unchange if np.abs(loss-new_loss) < 1e-6 and (new_label == label).all(): #print("The iteration stopped at {}".format(rep+1)) break #Update parameters label = np.copy(new_label) loss = np.copy(new_loss) #print("Loss value: {:.3f}".format(loss)) #Output the result corresponding to minimum loss if loss < minloss: out_label = np.copy(label) out_center = np.copy(center) minloss = loss #Finish the progress bar bar = "#" * int(np.ceil(num_init/unit)) print("\rk-means:[{0}] {1}/{2} {3:.2f}sec Completed!".format(bar, init+1, num_init, time.time()-start), end="") print() #Output the label vector and centroid matrix return out_label, out_center, minloss
K-meansのコードはもっと短く書くことも可能です。そういったコンパクトな書き方は「#You can use the following code for concise implementation」の下に数行コメントアウトした部分を参考にしてください(結果は完全一致します)。
このコードが長いのは私がアホであるという以外にも理由がありまして、次回の記事で紹介しようと思っているK-meansの進化版アルゴリズムK-shapeと見比べやすくするためです*1。
詳しくは割愛しますが、K-shapeはK-meansが苦手とする時系列データ(株価推移や音声データなど)のグループ分けに特化した、割と最新のアルゴリズムです。K-meansほどスマートに書けないため、このコードのようにfor文を使って愚直に実装します。
Fuzzy c-meansアルゴリズム
Fuzzy c-meansは、1984年にJ.C. Bezdekらによって発表された論文「FCM: The fuzzy c-means clustering algorithm」の中で紹介されました*2。
Fuzzyという名が示すとおり、このアルゴリズムのポイントは曖昧さです。すなわち、K-meansのように各データにユニークなグループ番号を割り当てることはせず、GMMのように確率的な分布(Soft clustering)を仮定します。
つまり、K-meansのようにはデータ番号だけに依存するベクトルではなく、グループ番号とデータ番号の両方に依存する行列になり、その行列要素は番データが番グループに所属する割合(確率)を示すものと解釈します。
また、Fuzzy c-meansでは曖昧さの程度を表すFuzzy係数というパラメータも導入されます。の極限でK-meansに一致し、1から離れるほど曖昧な所属を許容することになります*3。
Fuzzy c-meansもK-meansと同じで、グループの重心(Centroid)を計算するRefinement stepと、グループ番号への所属割合(Label)を割り当てるAssignment stepとを何度も繰り返して収束させる、反復型のアルゴリズムです。
まず、重心は以下のように計算されます。
この式は、所属割合の乗で重み付けをして全てのデータ点を平均化した、重み付き重心の計算式になっています*4。
続いて、ある入力データに対してグループ番号への所属割合を求めるには、論文中に示されている以下の計算式を用います。
以上のステップを交互に繰り返し、以下の損失関数が変化しなくなったら収束したものとみなして終了します。
Fuzzy c-meansをPythonで実装
Fuzzy c-meansはK-meansにグループ所属の曖昧さを持たせた、正当な拡張アルゴリズムです。そのコードの構造は上述した部分を除いて極めて類似しています。こちらもnumpyライブラリ等を使えばPythonで簡単(?)に実装することができます。
import sys import time import numpy as np from scipy import linalg #Function for getting c-means clustering def get_fuzzyCMeans(X, num_clu, max_iter, num_init, m): #Fuzzy coefficient m must be more than 1 if m <= 1: m = 1 + 1e-5 #Define the length of input N = int(X.shape[0]) #The number of data p = int(X.shape[1]) #The length of feature axis #For a progress bar unit = int(np.floor(num_init/10)) bar = "#" + " " * int(np.floor(num_init/unit)) start = time.time() #Repeat for each trial (initialization) minloss = np.inf for init in range(num_init): #Display a progress bar print("\rc-means:[{0}] {1}/{2} Processing..".format(bar, init, num_init), end="") if init % unit == 0: bar = "#" * int(np.ceil(init/unit)) + " " * int(np.floor((num_init-init)/unit)) print("\rc-means:[{0}] {1}/{2} Processing..".format(bar, init, num_init), end="") #Initialize label and centroid as random numbers label = np.random.rand(num_clu, N) label = label / np.sum(label, axis=0, keepdims=True) center = np.random.rand(num_clu, p) loss = np.inf #Repeat for each iteration for rep in range(max_iter): ### Refinement step (update center) ### #Calculate cluster centroids by using weights matrix (label) center = ((label**m) @ X) / np.sum(label**m, axis=1, keepdims=True) ### Assignment step (update label) ### #Initialize valuable for fuzzy-label new_label = np.zeros((num_clu, N)) #Compute fuzzy-label matrix for i in range(N): for clu in range(num_clu): #Compute norm for numerator numer_dist = np.linalg.norm(X[i, :]-center[clu, :]) #Get summation of the ratio between norms for j in range(num_clu): #Compute norm for denominator denom_dist = np.linalg.norm(X[i, :]-center[j, :]) if denom_dist < 1e-10: denom_dist = 1e-10 new_label[clu, i] = new_label[clu, i] + (numer_dist/denom_dist)**(2/(m-1)) #Avoid zero division new_label = np.where(new_label < 1e-10, 1e-10, new_label) new_label = new_label**(-1) #Normalization (it is needed due to error handling) new_label = new_label / np.sum(new_label, axis=0, keepdims=True) #Compute the loss function (generalized mean squares error) new_loss = 0 for i in range(N): for j in range(num_clu): #Compute the squared norm as distance dist = linalg.norm(X[i, :]-center[j, :])**2 new_loss = new_loss + (new_label[j, i]**m) * dist #Get out of the loop if loss and label unchange if np.abs(loss-new_loss) < 1e-6 and (new_label == label).all(): #print("The iteration stopped at {}".format(rep+1)) break #Update parameters label = np.copy(new_label) loss = np.copy(new_loss) #print("Loss value: {:.3f}".format(loss)) #Output the result corresponding to minimum loss if loss < minloss: out_label = np.copy(label) out_center = np.copy(center) minloss = loss #Finish the progress bar bar = "#" * int(np.ceil(num_init/unit)) print("\rc-means:[{0}] {1}/{2} {3:.2f}sec Completed!".format(bar, init+1, num_init, time.time()-start), end="") print() #Output the label vector and centroid matrix return out_label, out_center, minloss
アルゴリズムを理解してコードに落とし込むときにありがちなのですが、割り算をするときに分母が0になることを避ける例外処理が必ず必要になります。前節で示したとおりFuzzy c-meansはの計算式に割り算が出てくるので要注意です。
この関数から出力されるlabel_outは、行がグループ番号、列がデータ番号の行列なので、もしK-meansのように所属するグループを一意に決定したい場合は、np.argmax(label, axis=0)などを利用して確率が最大になる番号を抜き出しましょう。
実際にデータをグループ分けしてみよう
なんだか最近こればっかりで恐縮なのですが、以下のウェブサイトから入手できる各国のCOVID-19感染者データから、感染者全体に占める死亡率と快復率の2項目を入力データ(p=2次元の特徴量ベクトル)に採用し、K-meansやFuzzy c-meansで国を分類してみます。
このデータをExcelでコピペし、「corona_data.csv」というファイル名でPythonのコードと同じ階層に保存しました。以下のメイン関数からK-meansとFuzzy c-meansの関数を呼び出せば、matplotlibライブラリでグループ分けの結果を視覚化してくれます。
import sys import time import matplotlib.pyplot as plt import numpy as np from scipy import linalg #Main if __name__ == "__main__": #Setup num_clu = 6 #The number of cluster [Default]6 max_iter = 100 #The number of iteration [Default]100 num_init = 10 #The number of trial (initialization) [Default]10 demo = False #Using demo-data (random numbers) [Default]True clu_mode = "kmeans" #Clustering mode (kmeans or cmeans) [Default]kmeans m = 2.0 #Using cshape, specify the fuzzy parameter [Default]2.0 (>1.0) #Define random seed np.random.seed(seed=32) ### Data preparation step ### #Generate demo data by using Gaussian if demo == True: N = 300 X = np.concatenate([np.random.multivariate_normal([-2, 0], np.eye(2), round(N/num_clu)), np.random.multivariate_normal([3, -3], np.eye(2), round(N/num_clu)), np.random.multivariate_normal([4, 3], np.eye(2), round(N/num_clu)), np.random.multivariate_normal([-1, -4], np.eye(2), round(N/num_clu)), np.random.multivariate_normal([-4, -2], np.eye(2), round(N/num_clu)), np.random.multivariate_normal([-2, 4], np.eye(2), round(N/num_clu))]) #Using a data file else: #Read a data source (csv file) with open("./corona_data.csv", "r") as f: s = f.read().rstrip() lines = s.split("\n") #Define analysis axes x_axis, y_axis = 2, 3 #between 2 and 5 #Analysis_axes = ["Country", "Cases", "Deaths", "Recovered", "Serious", "Tests"] Analysis_axes = lines[0].split(",") lines = lines[1:] print("Horizontal axis: " + str(Analysis_axes[x_axis]) + " / Patients") print("Vertical axis: " + str(Analysis_axes[y_axis]) + " / Patients") #Construct the dataset X and the annotation A X, A = [], [] for i, line in enumerate(lines): line = line.split(",") #Remove the data if missing if line[1]!="" and line[4]!="" and line[x_axis]!="" and line[y_axis]!="": x_data = int(line[x_axis]) / int(line[1]) y_data = int(line[y_axis]) / int(line[1]) #z_data = int(line[4]) / int(line[1]) X.append([x_data, y_data]) A.append(line[0]) X, A = np.array(X), np.array(A) #Display graph print("Input data shape: {}".format(X.shape)) plt.rcParams["font.size"] = 16 plt.figure(figsize=(16, 12)) plt.scatter(X[:, 0], X[:, 1], c="yellow") if demo == True: plt.title("Demo-data samples (generated randomly)") else: #Annotation for i, a in enumerate(A): plt.annotate(a, xy=(X[i, 0], X[i, 1]), size=10) plt.title("COVID-19 data for each country") plt.xlabel(Analysis_axes[x_axis] + " / Patients") plt.ylabel(Analysis_axes[y_axis] + " / Patients") plt.show() ### Clustering step ### #Normalize input data (indispensable for clustering) ave = np.mean(X, axis=0, keepdims=True) X = X - ave std = np.std(X, axis=0, keepdims=True) X = X / std #Call my function for getting either k-means or fuzzy c-means clustering if clu_mode == "kmeans": #Call my function for getting k-means clustering label, center, loss = get_KMeans(X, num_clu, max_iter, num_init) elif clu_mode == "cmeans": #Call my function for getting fuzzy c-maens clustering fuzzy_label, center, loss = get_fuzzyCMeans(X, num_clu, max_iter, num_init, m) #print("Fuzzy label: {}".format(fuzzy_label)) #Harden the fuzzy label label = np.argmax(fuzzy_label, axis=0) else: print("'clu_mode' must be either 'kmeans' or 'cmeans'.") sys.exit() print("Label: {}".format(label)) #print("Centroid: {}".format(center)) print("Loss: {}".format(loss)) #Restore the original scale X = (X * std) + ave center = (center * std) + ave #Display graph plt.rcParams["font.size"] = 16 plt.figure(figsize=(16, 12)) plt.scatter(X[:, 0], X[:, 1], c=label) plt.scatter(center[:, 0], center[:, 1], s=250, marker='*', c='red') if clu_mode == "kmeans": plt.title("A clustering result by k-means") elif clu_mode == "cmeans": plt.title("A clustering result by fuzzy c-means") if demo == False: #Annotation for i, a in enumerate(A): plt.annotate(a, xy=(X[i, 0], X[i, 1]), size=10) plt.xlabel(Analysis_axes[x_axis] + " / Patients") plt.ylabel(Analysis_axes[y_axis] + " / Patients") plt.show()
いきなり実データを扱う前に、そもそもK-meansとFuzzy c-meansの実装が上手くいっているかどうか確かめるために、demo=Trueにするとデモデータを乱数で生成するようにしました。いくつか試してちゃんとグループ分けできたので、たぶん大丈夫です。
demo=Falseに設定して実行すると、まずはcorona_data.csvから読み込まれたデータから比率計算等を行い、以下のような散布図グラフが生成されます。
matplotlibにはannotationというプロパティで1つ1つのデータに名前を付けることができます。今回はデータが国を表しているのでウェブサイトのとおり英語表記で国名が表示されています。
横軸が感染者数に占める死亡者の割合、縦軸が感染者数に占める快復者の割合です。目安ではありますが、右下に行くほど悲惨な被害状況の国、左上に行くほど軽度な被害状況の国を表します*5。
これに対して、グループ数k=6でK-meansとFuzzy c-meansを実行した結果が以下のとおりです。
グループごとにランダムに異なる色を割り振るようにしました。赤色の星は各グループの重心位置を示しています。このくらいシンプルなデータだとK-meansとFuzzy c-meansとであまり差が出ないようです。
大きな被害が報道されているイタリア、スペイン、スウェーデンといった国々は右下のグループにいます。他方、韓国、台湾、ドイツといった対応がうまくいっている国々は左上のグループにいます。日本は途上国が多く所属する左下のグループです。
今回はグラフで視覚化するために死亡率と快復率という2次元の特徴量ベクトルを入力データにしましたが、他にも重症化率などの情報を付け足してベクトルの次元数を大きくすると、また違ったグループ分けになるかもしれません。
なお、以下のURLから入手できるGIA国際調査によるアンケートにおいて、「自国の政府はコロナウイルスにうまく対処していると思う(単数回答)」の項目を見てみると、興味深いことに上のグループ分けと満足度ランキングが全くリンクしていません。
例えば、タイは被害が最も小さいグループに属するにも関わらず、アンケートは最下位で、政府のCOVID-19対応に全く満足していないようです。国民の意識は、実情よりも政府に対する元々の信頼度に依存しているのかもしれません。
以上、教師無し機械学習の基本中の基本であるK-meansとFuzzy c-meansについて、COVID-19の実データを用いてグループ分けをしてみました。やっぱり実際のデータを分析するとアルゴリズムの理解が深まってとても勉強になりますね。
次回は時系列データのグループ分けに特化したK-shapeと呼ばれるクラスタリング手法についてご紹介し、これを音源分離に応用してみたいと思います。
稚文をお読みいただきありがとうございました。
*1:他にも、進捗状況を示すプログレスバーを設置したり、特定グループに所属するデータ数がゼロになってしまった場合のエラー処理を工夫したりして、さらに長くなっています。
*2:引用回数4700超えというスマッシュ・ヒットを達成した必読の論文です。
*3:用途にもよりますが、論文を読んでいると、多くの場合Fuzzy係数は1.25~2.00くらいに設定されるようです。
*4:物理で言うと、剛体力学の回転運動を記述する際に便利な慣性モーメントI(Moment of inertia)の計算に似ています。私の記憶が確かなら、質量を回転軸からの距離の2乗で重み付けして和を取ったはずです。
*5:ただし、快復報告についてはかなり国によって差が出ている(サバ読んでる?)のでちょっと信用できないかもしれません。