原文:https://www.burakkanber.com/blog/machine-learning-k-means-clustering-in-javascript-part-1/
作者:Burak Kanber
翻譯:王維強
在機器學習算法的幫助下我們能夠處理體量巨大的數據,我們可以向數據詢問一系列的問題,希望機器學習能給出答案。比如:某個數據點和哪些相似?通過對這些數據的分析能否總結出某種模式?給出歷史趨勢數據的前提下,能否判斷未來走勢? 諸如此類的問題都適用於在機器學習領域尋找答案。
介紹 & 動機
今天的議題是怎么把數據分類。比如,你正在為一家醫療影像設備公司工作,假如你已經找到了識別腫瘤細胞的方法,但是如果能有一種方法可以找出腫瘤細胞群的中心位置就更好了,這樣就可以使用機器人精准地達到目的地並清除它們。
我們需要找出一種聚類算法,這就是今天要特別討論的 k-means。
聚類
聚類算法通常來講就是要按照相似性給數據分類。如果你是一個電商經營者,就可以用聚類算法識別出各種購物者的類型。你可能會發現有一種顧客在瀏覽三五個產品頁面之后就會下單購買,另外一組用戶可能需要瀏覽多達15個頁面還要看很多評論才決定購買,而且會是一單高價值的購買行為,另外你可能還會注意到有一組沖動消費型的用戶,他們不用瀏覽太深入就會發生多次小額購買行為。一旦完成了對這些線上消費者的人口調查,你就能更好地優化站點提高銷售額。因為知道了自己的客戶中存在沖動型消費者,你就可以針對性地添加一些功能刺激這部分消費者。
k-means
和近鄰算法(k-nearest-neighbor)一樣,k-means 中的 k 也是一個數量值,是算法中的重要參數。需要特別指出的是,這里的 “k” 就是我們要在數據中找出來的簇(分組)的數量。不幸的是,很難在問題解決前知曉這個數量值,所以 k-means 算法通常需要在另外一個算法的幫助下找到最恰當的 k 值。
問題是 k-means 算法會把數據分割成 k 個獨特的簇, 但是算法不會告訴你 k 值是否正確。比如,你的數據在理想情況下應該被分成5個簇,但是如果人為地把 k 設定為3,那么你就會得到3個簇,當然這些簇的規模肯定大了一些,相對於理想狀態下的5個簇,精確度也下降了。
這個算法有優點也有短板,為了使用 k-means 算法你需要預先知道k的值,或者用另外的算法幫你猜測這個值。 k-means 只是幫你把數據分到不同的類別里,你還需要做些額外的工作找到簇的合適數目。
在今天例子中我們先設定簇的數目為3,下一篇文章將會討論自動猜測 k 值的算法,最常用到的就是誤差分析法結合反復調用 k-means 算法以優化結果,使誤差最小化。
過程
雖然 k-means 算法簡單,但是如果用在多維數據集上會表現出它強大的生命力。今天我們會處理一組2維數據,下次我們再把它做的復雜些。
算法過程如下:
- 以散點圖的方式,可視化數據。
- 創建 k 個新的數據點,隨機分布在圖上,把這些數據點作為簇的“重心”,也稱“簇重心備選者”。
- 重復下面的過程:
-
- 把距離重心最近的那些數據點分配給它
- 移動重心的位置到所有屬於它的數據點的平均位置上
- 如果重心的位置在最后一步中移動了,繼續重復上訴過程,否則退出。
機器委員會
該算法和很多我們在這個系列中將要講到算法的一樣,容易陷入局部最優解。如果你多運行下面的例子幾次,就會發現每次得到的結果都會有差別,這意味着一些結果陷入了不同的局部最優解。算法從一些隨機的,本身就容易受局部最優影響的種子開始,因為永遠不知道算法具體的開始位置和結果的走向,種子的狀態會導致局部最優還是全局最優?這些都無從知曉。就像遺傳算法一樣,能跳出局部最優解的一個方法就是使“解”發生一點兒突變。在此 k-means 例子中, 我們會在算法中加入一條規則,就是當發現重心數據點經過一輪迭代后沒有發生移動,那么就在一個在隨機方向上推一把。結果就是它可能又回到了原來的位置,也可能找到了一個新的解。這個推動不要大到讓計算從回起點,但是也要足夠把它踢出某個局部最優區域。另外一個我們可以使用的技巧被稱之為“機器委員會”,如果你的算法能結束的很快或者能使用並行計算,那么這個技巧很管用。其實很簡單,我們運行 k-means 算法3次,5次,51次或10000次,選擇那些最經常得到的解。“機器委員會” 是指一些人選擇在不同硬件上運行並行算法,一個字面上的機器委員會能給這些“解”投票。
代碼
我們開始代碼部分,和至今為止的其他例子不同,我會放棄面向對象的方法而采用直接了當的策略。解決問題的方法有很多,我喜歡OOP,但是重點是不要太依賴於習慣,還有就是我們在例子中使用的數據只是二維的,我願意把這個算法寫成可以處理任何維度的數據(除了畫板功能)。現在來看看我們要使用到的數據,非常簡單的數組,每個數據中的兩個值分別表示x,y。
var data = [ [1, 2], [2, 1], [2, 4], [1, 3], [2, 2], [3, 1], [1, 1], [7, 3], [8, 2], [6, 4], [7, 4], [8, 1], [9, 2], [10, 8], [9, 10], [7, 8], [7, 9], [8, 11], [9, 9], ];
接下來我們定義兩個方法,給定一個點的列表,我想知道其中在x和y兩個方向上的最大值和最小值,還有在兩個方向上的跨度。比如在X方向上是從1到11,在Y方向上是從3到7,了解這些對於怎么在畫板上畫圖很有幫助,當然也有利於我們隨機產生簇重心時參考。
我們應該在頭腦中始終保持一個理念,就是要讓函數在處理不同維度的數據時具有通用性:
function getDataRanges(extremes) { var ranges = []; for (var dimension in extremes) { ranges[dimension] = extremes[dimension].max - extremes[dimension].min; } return ranges; } function getDataExtremes(points) { var extremes = []; for (var i in data) { var point = data[i]; for (var dimension in point) { if ( ! extremes[dimension] ) { extremes[dimension] = {min: 1000, max: 0}; } if (point[dimension] < extremes[dimension].min) { extremes[dimension].min = point[dimension]; } if (point[dimension] > extremes[dimension].max) { extremes[dimension].max = point[dimension]; } } } return extremes; }
getDataExtremes()
函數用來遍歷每一個數據點並在所有維度上找出最大值和最小值(需要注意的是,這里有個一個硬編碼的數值“10000”,你需要根據具體情況做改變)。getDataRanges() 函數用來輔助返回每個維度的范圍(最大值減去最小值)。下一步我們定義 k 個簇並初始其隨機的重心位置:
function initMeans(k) { if ( ! k ) { k = 3; } while (k--) { var mean = []; for (var dimension in dataExtremes) { mean[dimension] = dataExtremes[dimension].min + ( Math.random() * dataRange[dimension] ); } means.push(mean); } return means; };
用該方法我們可以在數據集范圍之內隨機地生成幾個新的數據點,一旦我們擁有了這些像種子一樣的重心,就可以進入k-means循環過程了。如前所述,該循環過程包括首次為重心分配數據集里離該重心最近的那些點給它,然后移動重心位置到達這些點的平均重心位置,重復此過程直至重心停止移動。
function makeAssignments() { for (var i in data) { var point = data[i]; var distances = []; for (var j in means) { var mean = means[j]; var sum = 0; for (var dimension in point) { var difference = point[dimension] - mean[dimension]; difference *= difference; sum += difference; } distances[j] = Math.sqrt(sum); } assignments[i] = distances.indexOf( Math.min.apply(null, distances) ); } }
上面的函數會被我們的遍歷函數調用以計算每個點之間的歐幾里德距離和簇的重心位置。需要注意的是,該算法會遍歷每一個點到簇重心的距離,這是一個計算時間復雜度為O(k*n) 的算法,復雜度不是很恐怖,但是如果數據集比較龐大或者簇的數目較多,可能計算就比較密集了。不過可以通過一些途徑來優化,我們會在后續文章中談到。有一個我們現在就可以着手處理的是Math.sqrt()的效率問題,其實這個調用不必出現在對每個點的迭代計算過程中,一旦確定了分配列表,本例中這個列表就是一系列點的索引,我們就可以用所有點位置的平均值來更新簇重心的位置了。
譯者注:
歐幾里德距離是指在歐式空間中兩點間的距離,計算公式如下,假設p,q兩點為歐式空間中的兩個點,其各自的空間坐標為:
p(p1,p2,p3,... pn)q(q1,q2,q3,... qn)
則兩點間的距離可表示為:
function moveMeans() { makeAssignments(); var sums = Array( means.length ); var counts = Array( means.length ); var moved = false; for (var j in means) { counts[j] = 0; sums[j] = Array( means[j].length ); for (var dimension in means[j]) { sums[j][dimension] = 0; } } for (var point_index in assignments) { var mean_index = assignments[point_index]; var point = data[point_index]; var mean = means[mean_index]; counts[mean_index]++; for (var dimension in mean) { sums[mean_index][dimension] += point[dimension]; } } for (var mean_index in sums) { console.log(counts[mean_index]); if ( 0 === counts[mean_index] ) { sums[mean_index] = means[mean_index]; console.log("Mean with no points"); console.log(sums[mean_index]); for (var dimension in dataExtremes) { sums[mean_index][dimension] = dataExtremes[dimension].min + ( Math.random() * dataRange[dimension] ); } continue; } for (var dimension in sums[mean_index]) { sums[mean_index][dimension] /= counts[mean_index]; } } if (means.toString() !== sums.toString()) { moved = true; } means = sums; return moved; }
moveMeans() 方法在開始處調用
makeAssignments()
。一旦分配工作結束,我們需要初始化兩個數組:"sums" 和 "counts"。既然我們要計算算數平均值,我們就需要知道所有點在每個維度上的總和還有點的數量。
我們啟用三個遍歷過程:
一:遍歷每個簇重心,初始化數組sums在每個維度上的值為0,以及數組數量counts也為0。所以sums數組是一個多維數組,原因就在於我們處理的是每個簇中的每個點在每個維度上的數據。
二:遍歷每個被分配的數據點計算出每個簇重心擁有的數據點的數量,並且遍歷數據點的所有維度填充 sums 數組。到此,我們擁有了為簇重心計算新位置的所有數據。遍歷結果,為每個簇重心計算新的平均位置,並把重心移動到該位置。
三:檢測簇重心是否還有數據被分配給它,如果沒有,我們就給它一個隨機的新位置,就是我們之前說的踢它一腳。
最后,巡視所有的簇重心是否還有移動情況,並返回真或假。使用如下 setup 函數開始執行算法:
function setup() { canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); dataExtremes = getDataExtremes(data); dataRange = getDataRanges(dataExtremes); means = initMeans(3); makeAssignments(); draw(); setTimeout(run, drawDelay); } function run() { var moved = moveMeans(); draw(); if (moved) { setTimeout(run, drawDelay); } }
我們需要的設定都在setup()中初始化完成,之后
run() 函數檢測算法是否停止了,並根據時鍾的間隔設定循環執行,我們也就能實時看到算法的運行情況了。
k-medians
k-means算法存在一個問題,其實並不是算法本身的問題,而是算數平均值自身存在的缺陷,就是當數據中出現了某些數據飛地(偏離整體數據很遠),會給算數平均值帶來不利影響。比如,你所在的公司有五個人每年的薪水是5萬元,但是有另外一個人每年的薪水高達100萬,那么薪水中間值會是5萬(能代表公司的薪水情況),而平均值達到了20萬(完全不能代表公司薪資情況)! 這種問題當然也會在k-means算法中發生。如果你拿到的數據中有飛地情況,你會發現k-means算法得到的結果很糟糕,一個解決辦法就是使用 k-medians 代替 k-means, 二者算法相似,只是用中值代替平均值,這樣可以濾掉數據飛地的影響。另外,我認為在計算效率上也會比平均值法更高效。
結果
k-meeans 算法對於我們定義的整潔干凈的數據來說運行的非常完美。很顯然,如果數據臟亂,也會像其它算法一樣遇到困難。如果不厭其煩地多運行幾次我的代碼,你也會遇到陷於局部最優解的問題。這就需要通過“機器委員會”的技巧來解決了:通過一次次的運行,那些經常得出的結果就是我們要的答案。