一,引言
先說個K-means算法很高大上的用處,來開始新的算法學習。我們都知道每一屆的美國總統大選,那叫一個競爭激烈。可以說,誰拿到了各個州盡可能多的選票,誰選舉獲勝的幾率就會非常大。有人會說,這跟K-means算法有什么關系?當然,如果哪一屆的總統競選,某一位候選人是絕對的眾望所歸,那自然能以壓倒性優勢競選成功,那么我們的k-means算法還真用不上。但是,我們應該知道2004年的總統大選中,候選人的得票數非常接近,接近到什么程度呢?如果1%的選民將手中的選票投向任何一位候選人,都直接決定了總統的歸屬。那么這個時候,這1%的選民手中的選票就非常關鍵,因為他們的選票將直接對選舉結果產生非常大的影響,所以,如果能夠妥善加以引導和吸引,那么這很少的一部分選民還是極有可能會轉換立場的。那么如何找出這類選民,以及如何在有限的預算下采取措施來吸引他們呢?
答案就是聚類,這就要說到本次要講到的K-means算法了。通過收集用戶的信息,可以同時收集用戶滿意和不滿意的信息;然后將這些信息輸入到聚類算法中,就會得到很多的簇;接着,對聚類結果中的每一個簇(最好是最大簇),精心構造能吸引該簇選民的信息,加以引導;最后,再開展競選活動並觀察上述做法是否有效。而,一旦算法有效,那么就會對選舉結果產生非常大的影響,甚至,直接決定了最后的總統歸屬。
可見,聚類算法是一個非常了不起的算法。下面,我們就正式開始今天的新算法,K-means聚類算法。
二,K-means聚類算法
1 K-means算法的相關描述
聚類是一種無監督的學習,它將相似的對象歸到同一簇中。聚類的方法幾乎可以應用所有對象,簇內的對象越相似,聚類的效果就越好。K-means算法中的k表示的是聚類為k個簇,means代表取每一個聚類中數據值的均值作為該簇的中心,或者稱為質心,即用每一個的類的質心對該簇進行描述。
聚類和分類最大的不同在於,分類的目標是事先已知的,而聚類則不一樣,聚類事先不知道目標變量是什么,類別沒有像分類那樣被預先定義出來,所以,聚類有時也叫無監督學習。
聚類分析試圖將相似的對象歸入同一簇,將不相似的對象歸為不同簇,那么,顯然需要一種合適的相似度計算方法,我們已知的有很多相似度的計算方法,比如歐氏距離,余弦距離,漢明距離等。事實上,我們應該根據具體的應用來選取合適的相似度計算方法。
當然,任何一種算法都有一定的缺陷,沒有一種算法時完美的,有的只是人類不斷追求完美,不斷創新的意志。K-means算法也有它的缺陷,但是我們可以通過一些后處理來得到更好的聚類結果,這些在后面都會一一降到。
K-means算法雖然比較容易實現,但是其可能收斂到局部最優解,且在大規模數據集上收斂速度相對較慢。
2 K-means算法的工作流程
首先,隨機確定k個初始點的質心;然后將數據集中的每一個點分配到一個簇中,即為每一個點找到距其最近的質心,並將其分配給該質心所對應的簇;該步完成后,每一個簇的質心更新為該簇所有點的平均值。偽代碼如下:
創建k個點作為起始質心,可以隨機選擇(位於數據邊界內)
當任意一個點的簇分配結果發生改變時
對數據集中每一個點
對每個質心
計算質心與數據點之間的距離
將數據點分配到距其最近的簇
對每一個簇,計算簇中所有點的均值並將均值作為質心
再看實際的代碼:
#導入numpy庫 from numpy import * #K-均值聚類輔助函數 #文本數據解析函數 def numpy import * dataMat=[] fr=open(fileName) for line in fr.readlines(): curLine=line.strip().split('\t') #將每一行的數據映射成float型 fltLine=map(float,curLine) dataMat.append(fltLine) return dataMat #數據向量計算歐式距離 def distEclud(vecA,vecB): return sqrt(sum(power(vecA-vecB,2))) #隨機初始化K個質心(質心滿足數據邊界之內) def randCent(dataSet,k): #得到數據樣本的維度 n=shape(dataSet)[1] #初始化為一個(k,n)的矩陣 centroids=mat(zeros((k,n))) #遍歷數據集的每一維度 for j in range(n): #得到該列數據的最小值 minJ=min(dataSet[:,j]) #得到該列數據的范圍(最大值-最小值) rangeJ=float(max(dataSet[:,j])-minJ) #k個質心向量的第j維數據值隨機為位於(最小值,最大值)內的某一值 centroids[:,j]=minJ+rangeJ*random.rand(k,1) #返回初始化得到的k個質心向量 return centroids #k-均值聚類算法 #@dataSet:聚類數據集 #@k:用戶指定的k個類 #@distMeas:距離計算方法,默認歐氏距離distEclud() #@createCent:獲得k個質心的方法,默認隨機獲取randCent() def kMeans(dataSet,k,distMeas=distEclud,createCent=randCent): #獲取數據集樣本數 m=shape(dataSet)[0] #初始化一個(m,2)的矩陣 clusterAssment=mat(zeros((m,2))) #創建初始的k個質心向量 centroids=createCent(dataSet,k) #聚類結果是否發生變化的布爾類型 clusterChanged=True #只要聚類結果一直發生變化,就一直執行聚類算法,直至所有數據點聚類結果不變化 while clusterChanged: #聚類結果變化布爾類型置為false clusterChanged=False #遍歷數據集每一個樣本向量 for i in range(m): #初始化最小距離最正無窮;最小距離對應索引為-1 minDist=inf;minIndex=-1 #循環k個類的質心 for j in range(k): #計算數據點到質心的歐氏距離 distJI=distMeas(centroids[j,:],dataSet[i,:]) #如果距離小於當前最小距離 if distJI<minDist: #當前距離定為當前最小距離;最小距離對應索引對應為j(第j個類) minDist=distJI;minIndex=j #當前聚類結果中第i個樣本的聚類結果發生變化:布爾類型置為true,繼續聚類算法 if clusterAssment[i,0] !=minIndex:clusterChanged=True #更新當前變化樣本的聚類結果和平方誤差 clusterAssment[i,:]=minIndex,minDist**2 #打印k-均值聚類的質心 print centroids #遍歷每一個質心 for cent in range(k): #將數據集中所有屬於當前質心類的樣本通過條件過濾篩選出來 ptsInClust=dataSet[nonzero(clusterAssment[:,0].A==cent)[0]] #計算這些數據的均值(axis=0:求列的均值),作為該類質心向量 centroids[cent,:]=mean(ptsInClust,axis=0) #返回k個聚類,聚類結果及誤差 return centroids,clusterAssment
需要說明的是,在算法中,相似度的計算方法默認的是歐氏距離計算,當然也可以使用其他相似度計算函數,比如余弦距離;算法中,k個類的初始化方式為隨機初始化,並且初始化的質心必須在整個數據集的邊界之內,這可以通過找到數據集每一維的最大值和最小值;然后最小值+取值范圍*0到1的隨機數,來確保隨機點在數據邊界之內。
在實際的K-means算法中,采用計算質心-分配-重新計算質心的方式反復迭代,算法停止的條件是,當然數據集所有的點分配的距其最近的簇不在發生變化時,就停止分配,更新所有簇的質心后,返回k個類的質心(一般是向量的形式)組成的質心列表,以及存儲各個數據點的分類結果和誤差距離的平方的二維矩陣。
上面返回的結果中,之所以存儲每個數據點距離其質心誤差距離平方,是便於后續的算法預處理。因為K-means算法采取的是隨機初始化k個簇的質心的方式,因此聚類效果又可能陷入局部最優解的情況,局部最優解雖然效果不錯,但不如全局最優解的聚類效果更好。所以,后續會在算法結束后,采取相應的后處理,使算法跳出局部最優解,達到全局最優解,獲得最好的聚類效果。
可以看一個聚類的例子:
3 后處理提高聚類性能
有時候當我們觀察聚類的結果圖時,發現聚類的效果沒有那么好,如上圖所示,K-means算法在k值選取為3時的聚類結果,我們發現,算法能夠收斂但效果較差。顯然,這種情況的原因是,算法收斂到了局部最小值,而並不是全局最小值,局部最小值顯然沒有全局最小值的結果好。
那么,既然知道了算法已經陷入了局部最小值,如何才能夠進一步提升K-means算法的效果呢?
一種用於度量聚類效果的指標是SSE,即誤差平方和, 為所有簇中的全部數據點到簇中心的誤差距離的平方累加和。SSE的值如果越小,表示數據點越接近於它們的簇中心,即質心,聚類效果也越好。因為,對誤差取平方后,就會更加重視那些遠離中心的數據點。
顯然,我們知道了一種改善聚類效果的做法就是降低SSE,那么如何在保持簇數目不變的情況下提高簇的質量呢?
一種方法是:我們可以將具有最大SSE值得簇划分為兩個簇(因為,SSE最大的簇一般情況下,意味着簇內的數據點距離簇中心較遠),具體地,可以將最大簇包含的點過濾出來並在這些點上運行K-means算法,其中k設為2.
同時,當把最大的簇(上圖中的下半部分)分為兩個簇之后,為了保證簇的數目是不變的,我們可以再合並兩個簇。具體地:
一方面我們可以合並兩個最近的質心所對應的簇,即計算所有質心之間的距離,合並質心距離最近的兩個質心所對應的簇。
另一方面,我們可以合並兩個使得SSE增幅最小的簇,顯然,合並兩個簇之后SSE的值會有所上升,那么為了最好的聚類效果,應該盡可能使總的SSE值小,所以就選擇合並兩個簇后SSE漲幅最小的簇。具體地,就是計算合並任意兩個簇之后的總得SSE,選取合並后最小的SSE對應的兩個簇進行合並。這樣,就可以滿足簇的數目不變。
上面,是對已經聚類完成的結果進行改善的方法,在不改變k值的情況下,上述方法能夠起到一定的作用,會使得聚類效果得到一定的改善。那么,下面要講到的是一種克服算法收斂於局部最小值問題的K-means算法。即二分k-均值算法。
三,二分K-means算法
二分K-means算法首先將所有點作為一個簇,然后將簇一分為二。之后選擇其中一個簇繼續進行划分,選擇哪一個簇取決於對其進行划分是否能夠最大程度的降低SSE的值。上述划分過程不斷重復,直至划分的簇的數目達到用戶指定的值為止。
二分K-means算法的偽代碼如下:
將所有點看成一個簇 當簇數目小於k時 對於每一個簇 計算總誤差 在給定的簇上面進行k-均值聚類(k=2) 計算將該簇一分為二之后的總誤差 選擇使得總誤差最小的簇進行划分
當然,也可以選擇SSE最大的簇進行划分,知道簇數目達到用戶指定的數目為止。下面看具體的代碼:
#二分K-均值聚類算法 #@dataSet:待聚類數據集 #@k:用戶指定的聚類個數 #@distMeas:用戶指定的距離計算方法,默認為歐式距離計算 def biKmeans(dataSet,k,distMeas=distEclud): #獲得數據集的樣本數 m=shape(dataSet)[0] #初始化一個元素均值0的(m,2)矩陣 clusterAssment=mat(zeros((m,2))) #獲取數據集每一列數據的均值,組成一個長為列數的列表 centroid0=mean(dataSet,axis=0).tolist()[0] #當前聚類列表為將數據集聚為一類 centList=[centroid0] #遍歷每個數據集樣本 for j in range(m): #計算當前聚為一類時各個數據點距離質心的平方距離 clusterAssment[j,1]=distMeas(mat(centroid0),dataSet[j,:])**2 #循環,直至二分k-均值達到k類為止 while (len(centList)<k): #將當前最小平方誤差置為正無窮 lowerSSE=inf #遍歷當前每個聚類 for i in range(len(centList)): #通過數組過濾篩選出屬於第i類的數據集合 ptsInCurrCluster=\ dataSet[nonzero(clusterAssment[:,0].A==i)[0],:] #對該類利用二分k-均值算法進行划分,返回划分后結果,及誤差 centroidMat,splitClustAss=\ kMeans(ptsInCurrCluster,2,distMeas) #計算該類划分后兩個類的誤差平方和 sseSplit=sum(splitClustAss[:,1]) #計算數據集中不屬於該類的數據的誤差平方和 sseNotSplit=\ sum(clusterAssment[nonzero(clusterAssment[:,0].A!=i)[0],1]) #打印這兩項誤差值 print('sseSplit,and notSplit:',%(sseSplit,sseNotSplit)) #划分第i類后總誤差小於當前最小總誤差 if(sseSplit+sseNotSplit)<lowerSSE: #第i類作為本次划分類 bestCentToSplit=i #第i類划分后得到的兩個質心向量 bestNewCents=centroidMat #復制第i類中數據點的聚類結果即誤差值 bestClustAss=splitClustAss.copy() #將划分第i類后的總誤差作為當前最小誤差 lowerSSE=sseSplit+sseNotSplit #數組過濾篩選出本次2-均值聚類划分后類編號為1數據點,將這些數據點類編號變為 #當前類個數+1,作為新的一個聚類 bestClustAss[nonzero(bestClustAss[:,0].A==1)[0],0]=\ len(centList) #同理,將划分數據集中類編號為0的數據點的類編號仍置為被划分的類編號,使類編號 #連續不出現空缺 bestClustAss[nonzero(bestClustAss[:,0].A==0)[0],0]=\ bestCentToSplit #打印本次執行2-均值聚類算法的類 print('the bestCentToSplit is:',%bestCentToSplit) #打印被划分的類的數據個數 print('the len of bestClustAss is:',%(len(bestClustAss))) #更新質心列表中的變化后的質心向量 centList[bestCentToSplit]=bestNewCents[0,:] #添加新的類的質心向量 centList.append(bestNewCents[1,:]) #更新clusterAssment列表中參與2-均值聚類數據點變化后的分類編號,及數據該類的誤差平方 clusterAssment[nonzero(clusterAssment[:,0].A==\ bestCentToSplit)[0],:]=bestClustAss #返回聚類結果 return mat(centList),clusterAssment
在上述算法中,直到簇的數目達到k值,算法才會停止。在算法中通過將所有的簇進行划分,然后分別計算划分后所有簇的誤差。選擇使得總誤差最小的那個簇進行划分。划分完成后,要更新簇的質心列表,數據點的分類結果及誤差平方。具體地,假設划分的簇為m(m<k)個簇中的第i個簇,那么這個簇分成的兩個簇后,其中一個取代該被划分的簇,成為第i個簇,並計算該簇的質心;此外,將划分得到的另外一個簇,作為一個新的簇,成為第m+1個簇,並計算該簇的質心。此外,算法中還存儲了各個數據點的划分結果和誤差平方,此時也應更新相應的存儲信息。這樣,重復該過程,直至簇個數達到k。
通過上述算法,之前陷入局部最小值的的這些數據,經過二分K-means算法多次划分后,逐漸收斂到全局最小值,從而達到了令人滿意的聚類效果。
四,示例:對地圖上的點進行聚類
現在有一個存有70個地址和城市名的文本,而沒有這些地點的距離信息。而我們想要對這些地點進行聚類,找到每個簇的質心地點,從而可以安排合理的行程,即質心之間選擇交通工具抵達,而位於每個質心附近的地點就可以采取步行的方法抵達。顯然,K-means算法可以為我們找到一種更加經濟而且高效的出行方式。
1 通過地址信息獲取相應的經緯度信息
那么,既然沒有地點之間的距離信息,怎么計算地點之間的距離呢?又如何比較地點之間的遠近呢?
我們手里只有各個地點的地址信息,那么如果有一個API,可以讓我們輸入地點信息,返回該地點的經度和緯度信息,那么我們就可以通過球面距離計算方法得到兩個地點之間的距離了。而Yahoo!PlaceFinder API可以幫助我們實現這一目標。獲取地點信息對應經緯度的代碼如下:
#Yahoo!PlaceFinder API #導入urllib import urllib #導入json模塊 import json #利用地名,城市獲取位置經緯度函數 def geoGrab(stAddress,city): #獲取經緯度網址 apiStem='http://where.yahooapis.com/geocode?' #初始化一個字典,存儲相關參數 params={} #返回類型為json params['flags']='J' #參數appid params['appid']='ppp68N8t' #參數地址位置信息 params['location']=('%s %s', %(stAddress,city)) #利用urlencode函數將字典轉為URL可以傳遞的字符串格式 url_params=urllib.urlencode(params) #組成完整的URL地址api yahooApi=apiStem+url_params #打印該URL地址 print('%s',yahooApi) #打開URL,返回json格式的數據 c=urllib.urlopen(yahooApi) #返回json解析后的數據字典 return json.load(c.read()) from time import sleep #具體文本數據批量地址經緯度獲取函數 def massPlaceFind(fileName): #新建一個可寫的文本文件,存儲地址,城市,經緯度等信息 fw=open('places.txt','wb+') #遍歷文本的每一行 for line in open(fileName).readlines(); #去除首尾空格 line =line.strip() #按tab鍵分隔開 lineArr=line.split('\t') #利用獲取經緯度函數獲取該地址經緯度 retDict=geoGrab(lineArr[1],lineArr[2]) #如果錯誤編碼為0,表示沒有錯誤,獲取到相應經緯度 if retDict['ResultSet']['Error']==0: #從字典中獲取經度 lat=float(retDict['ResultSet']['Results'][0]['latitute']) #維度 lng=float(retDict['ResultSet']['Results'][0]['longitute']) #打印地名及對應的經緯度信息 print('%s\t%f\t%f',%(lineArr[0],lat,lng)) #將上面的信息存入新的文件中 fw.write('%s\t%f\t%f\n',%(line,lat,lng)) #如果錯誤編碼不為0,打印提示信息 else:print('error fetching') #為防止頻繁調用API,造成請求被封,使函數調用延遲一秒 sleep(1) #文本寫入關閉 fw.close()
在上述代碼中,首先創建一個字典,字典里面存儲的是通過URL獲取經緯度所必要的參數,即我們想要的返回的數據格式flogs=J;獲取數據的appid;以及要輸入的地址信息(stAddress,city)。然后,通過urlencode()函數幫助我們將字典類型的信息轉化為URL可以傳遞的字符串格式。最后,打開URL獲取返回的JSON類型數據,通過JSON工具來解析返回的數據。且在返回的結果中,當錯誤編碼為0時表示,得到了經緯度信息,而為其他值時,則表示返回經緯度信息失敗。
此外,在代碼中,每次獲取完一個地點的經緯度信息后,延遲一秒鍾。這樣做的目的是為了避免頻繁的調用API,請求被封掉的情況。
2 對地理位置進行聚類
我們已經得到了各個地點的經緯度信息,但是我們還要選擇計算距離的合適的方式。我們知道,在北極每走幾米的經度變化可能達到數十度,而沿着赤道附近走相同的距離,帶來的經度變化可能是零。這是,我們可以使用球面余弦定理來計算兩個經緯度之間的實際距離。具體代碼如下:
#球面距離計算及簇繪圖函數 def distSLC(vecA,vecB): #sin()和cos()以弧度未輸入,將float角度數值轉為弧度,即*pi/180 a=sin(vecA[0,1]*pi/180)*sin(vecB[0,1]*pi/180) b=cos(vecA[0,1]*pi/180)*cos(vecB[0,1]*pi/180)*\ cos(pi*(vecB[0,0]-vecA[0,0])/180) return arcos(a+b)*6371.0 import matplotlib import matplotlib.pyplot as plt #@numClust:聚類個數,默認為5 def clusterClubs(numClust=5): datList=[] #解析文本數據中的每一行中的數據特征值 for line in open('places.txt').readlines(): lineArr=line.split('\t') datList.append([float(lineArr[4]),float(lineArr[4])]) datMat=mat(datList) #利用2-均值聚類算法進行聚類 myCentroids,clusterAssing=biKmeans(datMat,numClust,\ distMeas=distSLC) #對聚類結果進行繪圖 fig=plt.figure() rect=[0.1,0.1,0.8,0.8] scatterMarkers=['s','o','^','8'.'p',\ 'd','v','h','>','<'] axprops=dict(xticks=[],ytick=[]) ax0=fig.add_axes(rect,label='ax0',**axprops) imgP=plt.imread('Portland.png') ax0.imshow(imgP) ax1=fig.add_axes(rect,label='ax1',frameon=False) for i in range(numClust): ptsInCurrCluster=datMat[nonzero(clusterAssing[:,0].A==i)[0],:] markerStyle=scatterMarkers[i % len(scatterMarkers))] ax1.scatter(ptsInCurrCluster[:,0].flatten().A[0],\ ptsInCurrCluster[:,1].flatten().A[0],\ marker=markerStyle,s=90) ax1.scatter(myCentroids[:,0].flatten().A[0],\ myCentroids[:,1].flatten().A[0],marker='+',s=300) #繪制結果顯示 plt.show()
最后,將聚類的結果繪制出來:
五,小結
1 聚類是一種無監督的學習方法。聚類區別於分類,即事先不知道要尋找的內容,沒有預先設定好的目標變量。
2 聚類將數據點歸到多個簇中,其中相似的數據點歸為同一簇,而不相似的點歸為不同的簇。相似度的計算方法有很多,具體的應用選擇合適的相似度計算方法
3 K-means聚類算法,是一種廣泛使用的聚類算法,其中k是需要指定的參數,即需要創建的簇的數目,K-means算法中的k個簇的質心可以通過隨機的方式獲得,但是這些點需要位於數據范圍內。在算法中,計算每個點到質心得距離,選擇距離最小的質心對應的簇作為該數據點的划分,然后再基於該分配過程后更新簇的質心。重復上述過程,直至各個簇的質心不再變化為止。
4 K-means算法雖然有效,但是容易受到初始簇質心的情況而影響,有可能陷入局部最優解。為了解決這個問題,可以使用另外一種稱為二分K-means的聚類算法。二分K-means算法首先將所有數據點分為一個簇;然后使用K-means(k=2)對其進行划分;下一次迭代時,選擇使得SSE下降程度最大的簇進行划分;重復該過程,直至簇的個數達到指定的數目為止。實驗表明,二分K-means算法的聚類效果要好於普通的K-means聚類算法。