機器學習筆記(3)——使用聚類分析演算法對文字分類(分類數k未知)
聚類分析是一種無監督機器學習(訓練樣本的標記資訊是未知的)演算法,它的目標是將相似的物件歸到同一個簇中,將不相似的物件歸到不同的簇中。如果要使用聚類分析演算法對一堆文字分類,關鍵要解決這幾個問題:
- 如何衡量兩個物件是否相似
- 演算法的效能怎麼度量
- 如何確定分類的個數或聚類結束的條件
- 選擇哪種分類演算法
下面就帶著這幾個問題,以我工作中的一個業務需求為例,來學習一下怎麼對中文文字進行聚類。(此文略長,包含了理論基礎、演算法院裡、程式碼實現和實驗效果分析)
一. 業務需求
我在工作中遇到這樣一個需求:有個鐵路通訊專業的業務系統,收集了一些通訊裝置的生產廠商資訊,共4500多條。由於資料是人工錄入的,非常不規範,存在資料重複、資訊不規範、錯別字等現象,需要對資料進行分析歸類,將相同或相似的資料劃到一起,再通過人工稽核規範資料,最終形成規範的字典資料。
二、理論基礎
1. 相似度計算
- 歐氏距離
歐氏距離是一種常用的距離定義,指在m維空間中兩個點之間的真實距離,對多維向量A=(A1,A2,……,An),B=(B1,B2,……,Bn),歐氏距離的計算公式如下:
-
餘弦相似度
餘弦相似度用向量空間中兩個向量夾角的餘弦值作為衡量兩個個體差異的大小。相比歐氏距離度量,餘弦相似度更加註重兩個向量在方向上的差異,而非距離或長度上的差異。餘弦值的計算公式如下:
相對於歐氏距離,餘弦相似度更適合計算文字的相似度。首先將文字轉換為權值向量,通過計算兩個向量的夾角餘弦值,就可以評估他們的相似度。餘弦值的範圍在[-1,1]之間,值越趨近於1,代表兩個向量方向越接近;越趨近於-1,代表他們的方向越相反。為了方便聚類分析,我們將餘弦值做歸一化處理,將其轉換到[0,1]之間,並且值越小距離越近。
2. 效能度量
在選擇聚類演算法之前,首先來了解什麼樣的聚類結果是比較好的。我們希望同一個簇內的樣本儘可能相似,不同簇的樣本儘可能不同,也就是說聚類結果的“簇內相似度”高且“簇間相似度”低。
考慮聚類結果的簇劃分, 定義:
其中,代表簇的中心點; 代表簇內樣本的平均距離;代表簇內樣本間的最遠距離;對應於簇和簇最近樣本間的距離;對應於簇和 中心點間的距離。基於以上公式可匯出下面兩個常用的聚類效能度量內部指標:
- DB指數(Davies-Bouldin Index,簡稱DBI)
- Dumn指數(Dumn Index,簡稱DI)
DB指數的計算方法是任意兩個簇內樣本的平均距離之和除以兩個簇的中心點距離,並取最大值,DBI的值越小,意味著簇內距離越小,同時簇間的距離越大;Dumn指數的計算方法是任意兩個簇的最近樣本間的距離除以簇內樣本的最遠距離的最大值,並取最小值,DI的值越大,意味著簇間距離大而簇內距離小。因此,DBI的值越小,同時DI的值越大,意味著聚類的效果越好
三、聚類過程
有了相似度計算方法和效能度量這兩個理論基礎,下面就開始對文字分類了。
1. 分詞
要對中文文字做聚類分析,首先要對文字做分詞處理,例如“聯想行動通訊科技有限公司”,我們希望將其切分為“聯想 移動 通訊 科技 有限 公司”。python提供專門的中文切詞工具“jieba”,它可以將中文長文字劃分為若干個單詞。
為了提高分類的準確率,還要考慮兩個干擾因素:一是英文字母大小寫的影響,為此我們將英文字母統一轉換為大寫;二是例如 “有限”、“責任”、“股份”、“公司”等通用的詞彙,我們將這樣的詞彙連同“()”、“-”、“/”、“&”等符號作為停用詞,將其從分詞結果中去除掉,最後得到有效的詞彙組合,程式碼和結果如下:
# 載入停用詞,這裡主要是排除“有限公司”一類的通用詞
def loadStopWords(fileName):
dataMat = []
fr = open(fileName)
words = fr.read()
result = jb.cut(words, cut_all=True)
newWords = []
for s in result:
if s not in newWords:
newWords.append(s)
newWords.extend([u'(', u')', '(', ')', '/', '-', '.', '-', '&'])
return newWords
# 把文字分詞並去除停用詞,返回陣列
def wordsCut(words, stopWordsFile):
result = jb.cut(words)
newWords = []
stopWords = loadStopWords(stopWordsFile)
for s in result:
if s not in stopWords:
newWords.append(s)
return newWords
# 把樣本檔案做分詞處理,並寫檔案
def fileCut(fileName, writeFile, stopWordsFile):
dataMat = []
fr = open(fileName)
frW = open(writeFile, 'w')
for line in fr.readlines():
curLine = line.strip()
curLine1 = curLine.upper() # 把字串中的英文字母轉換成大寫
cutWords = wordsCut(curLine1, stopWordsFile)
for i in range(len(cutWords)):
frW.write(cutWords[i])
frW.write('\t')
frW.write('\n')
dataMat.append(cutWords)
frW.close()
2. 構建詞袋模型
文字被切分成單詞後,需要進一步轉換成向量。先將所有文字中的詞彙構建成一個詞條列表,其中不含重複的詞條。然後對每個文字,構建一個向量,向量的維度與詞條列表的維度相同,向量的值是詞條列表中每個詞條在該文字中出現的次數,這種模型叫做詞袋模型。例如,“阿爾西集團”和“阿爾西製冷工程技術(北京)有限公司”兩個文字切詞後的結果是“阿爾西 集團”和“阿爾西 製冷 工程技術 北京”,它們構成的詞條列表是[阿爾西, 集團, 製冷, 工程技術, 北京],對應的詞袋模型分別是[1,1,0,0,0],[1,0,1,1,1]。
# 建立不重複的詞條列表
def createVocabList(dataSet):
vocabSet = set([])
for document in dataSet:
vocabSet = vocabSet | set(document)
return list(vocabSet)
# 將文字轉化為詞袋模型
def bagOfWords2Vec(vocabList, inputSet):
returnVec = [0] * len(vocabList)
for word in inputSet:
if word in vocabList:
returnVec[vocabList.index(word)] += 1
else:
print "the word: %s is not in my Vocabulary!" % word
return returnVec
3. 權值轉換
TF-IDF是一種統計方法,用來評估一個詞條對於一個檔案集中一份檔案的重要程度。TF-IDF的主要思想是:如果某個詞在一篇文章中出現的頻率TF高,並且在其他檔案中很少出現,則認為此詞條具有很好的類別區分能力,適合用來分類。將詞袋向量轉換為TF-IDF權值向量,更有利於判斷兩個文字的相似性。
- TF(詞頻,term frequency):
分子是詞條在檔案中出現的次數,分母是檔案中所有詞條出現的次數之和。
- IDF(逆向檔案頻率,inverse document frequency):
對數內的分子是檔案總數,分母是包含詞條的檔案數,如果該詞不存在,就會導致分母為零,因此一般使用作為分母。
# 計算所有文字包含的總詞數
def wordsCount(dataSet):
wordsCnt = 0
for document in dataSet:
wordsCnt += len(document)
return wordsCnt
# 計算包含某個詞的文字數
def wordInFileCount(word, cutWordList):
fileCnt = 0
for i in cutWordList:
for j in i:
if word == j:
fileCnt = fileCnt + 1
else:
continue
return fileCnt
# 計算權值,並存儲為txt
def calTFIDF(dataSet, writeFile):
allWordsCnt = wordsCount(dataSet) # 所有文字的總詞數
fileCnt = len(dataSet) # 文字數
vocabList = createVocabList(dataSet) # 詞條列表
# tfidfSet = []
frW = open(writeFile, 'w')
for line in dataSet:
wordsBag = bagOfWords2Vec(vocabList, line) # 每行文字對應的詞袋向量
lineWordsCnt = 0
for i in range(len(wordsBag)):
lineWordsCnt += wordsBag[i] # 計算每個文字中包含的總詞數
tfidfList = [0] * len(vocabList)
for word in line:
wordinfileCnt = wordInFileCount(word, dataSet) # 包含該詞的文字數
wordCnt = wordsBag[vocabList.index(word)] # 該詞在文字中出現的次數
tf = float(wordCnt) / lineWordsCnt
idf = math.log(float(fileCnt) / (wordinfileCnt + 1))
tfidf = tf * idf
tfidfList[vocabList.index(word)] = tfidf
frW.write('\t'.join(map(str, tfidfList)))
frW.write('\n')
# tfidfSet.append(tfidfList)
frW.close()
4. 計算餘弦相似度
前面已經介紹過,相對歐氏距離,餘弦相似度更適合文字分類,Python實現如下:
# 計算餘弦距離
def gen_sim(A, B):
num = float(dot(mat(A), mat(B).T))
denum = linalg.norm(A) * linalg.norm(B)
if denum == 0:
denum = 1
cosn = num / denum
sim = 0.5 + 0.5 * cosn # 餘弦值為[-1,1],歸一化為[0,1],值越大相似度越大
sim = 1 - sim # 將其轉化為值越小距離越近
return sim
5. 使用K-均值聚類演算法分類
K-均值是將資料集劃分為k個簇的演算法,簇的個數k是使用者給定的,每個簇通過其質心(簇中所有點的中心)來描述。K-均值演算法的工作流程是:
(1)隨機確定k個初始點作為質心。
(2)將資料集中的每個點找到距離最近的質心,並將其分配到該質心對應的簇中。
(3)將每個簇的質心更新為該簇中所有點的平均值。
(4)重複第(2)、(3)步驟,直到簇的分配結果不再變化。
為了評價聚類的質量,定義一種用於衡量聚類效果的指標SSE(Sum of Squared Error,誤差平方和),誤差是指樣本到其質心的距離。SSE值越小,表示資料點越接近質心。
由於K-均值演算法是隨機選取質心,因此可能會收斂到區域性最小值,而非全域性最小值。為了克服這個問題,提出了一種二分K-均值演算法。該演算法的思路是將所有點作為一個簇,然後將該簇一分為二。之後選擇一個能最大程度降低SSE值的簇繼續進行劃分,直到得到使用者指定的簇數目為止。
注意:該演算法需要確定簇的個數,而我的需求中分類的個數是未知的。因此,希望通過觀察效能度量指標DI和DBI的變化趨勢來確定一個合適k值。
效能度量指標的實現:
# 計算簇內兩個樣本間的最大距離
def diamM(dataSet):
maxDist = 0
m = shape(dataSet)[0]
if m > 1:
for i in range(m):
for j in range(i + 1, m):
dist = gen_sim(dataSet[i, :], dataSet[j, :])
if dist > maxDist:
maxDist = dist
return maxDist
# 計算兩個簇間,樣本間的最小距離
def dMin(dataSet1, dataSet2):
minDist = 1
m = shape(dataSet1)[0]
n = shape(dataSet2)[0]
for i in range(m):
for j in range(n):
dist = gen_sim(dataSet1[i, :], dataSet2[j, :])
if dist < minDist:
minDist = dist
return minDist
# 計算簇內樣本間的平均距離
def avg(dataSet):
m = shape(dataSet)[0]
dist = 0
avgDist = 0
if m > 1:
for i in range(m):
for j in range(i + 1, m):
dist += gen_sim(dataSet[i, :], dataSet[j, :])
avgDist = float(2 * dist) / (m * (m - 1))
return avgDist
二分K-均值演算法實現:
def biKmeans(dataSet, k, distMeas=gen_sim):
m = shape(dataSet)[0]
clusterAssment = mat(zeros((m, 2)))
SSE = [] # 用於記錄每次迭代的總誤差
DI = 0 # DI指數,用於衡量簇間的相似度,值越大越好
DBI = 0
dmin = 1
diam = []
diam.append(diamM(dataSet))
centroid0 = mean(dataSet, axis=0).tolist()[0]
centList = [centroid0] # 建立質心列表,初始只有一個質心
for j in range(m): # 計算初始的平方誤差
clusterAssment[j, 1] = distMeas(centroid0, dataSet[j, :]) ** 2
SSE.append([0, sum(clusterAssment[:, 1]), 1, 0])
while (len(centList) < k): # 聚類數小於k時
lowestSSE = inf
for i in range(len(centList)): # 對每個質心迴圈
# 獲取第i個質心對應的資料集(簇)
ptsInCurrCluster = dataSet[nonzero(clusterAssment[:, 0].A == i)[0],
:]
# 對該簇使用k均值演算法,分為2個簇
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
if (sseSplit + sseNotSplit) < lowestSSE: # 尋找最小誤差對應的簇
bestCentToSplit = i
bestNewCents = centroidMat
bestClustAss = splitClustAss.copy()
lowestSSE = sseSplit + sseNotSplit
# 更新簇的分配結果,將多分出來的簇作為最後一個簇
bestClustAss[nonzero(bestClustAss[:, 0].A == 1)[0], 0] = len(
centList) # change 1 to 3,4, or whatever
bestClustAss[nonzero(bestClustAss[:, 0].A == 0)[0], 0] = bestCentToSplit
# 更新質心列表
centList[bestCentToSplit] = bestNewCents[0, :].tolist()[0]
centList.append(bestNewCents[1, :].tolist()[0])
# 更新分類結果
clusterAssment[nonzero(clusterAssment[:, 0].A == bestCentToSplit)[0],
:] = bestClustAss
index = len(centList);
error = sum(clusterAssment[:, 1])
# 新劃分的兩個簇
newDataSet1 = dataSet[
nonzero(clusterAssment[:, 0].A == bestCentToSplit)[0], :]
newDataSet2 = dataSet[
nonzero(clusterAssment[:, 0].A == len(centList) - 1)[0],
:]
# 計算DI指數,該值越大越好
diam1 = diamM(newDataSet1)
diam2 = diamM(newDataSet2)
diam[bestCentToSplit] = diam1
diam.append(diam2)
for l in range(len(centList) - 1):
dataSetl = dataSet[nonzero(clusterAssment[:, 0].A == l)[0], :]
dist = dMin(dataSetl, newDataSet2)
if dist < dmin:
dmin = dist
DI = float(dmin) / max(diam)
# 計算DBI指數,該值越小越好
maxDBI = 0
sumDBI = 0
DBI = 0
for i in range(len(centList)):
for j in range(i + 1, len(centList)):
dataSeti = dataSet[nonzero(clusterAssment[:, 0].A == i)[0], :]
dataSetj = dataSet[nonzero(clusterAssment[:, 0].A == j)[0], :]
DBIij = (avg(dataSeti) + avg(dataSetj)) / gen_sim(
mat(centList[i]), mat(centList[j]))
if DBIij > maxDBI:
maxDBI = DBIij
sumDBI += maxDBI
DBI = sumDBI / len(centList)
SSE.append([index, error, DI, DBI])
print '---' + getTime() + '---'
print u'誤差最小的簇是: ', bestCentToSplit
print u'該簇的長度是: ', len(bestClustAss)
print u'分為%d個簇,誤差是%f' % (index, error)
print u'分為%d個簇,DI值是%f,DBI值是%f' % (index, DI, DBI)
return mat(centList), clusterAssment, mat(SSE)
由於計算DI和DBI值的複雜度較高,先選取500多個樣本測試一下效果,得到的趨勢如下圖所示,然而結果並不理想,DBI值趨於不變,DI值的變化趨勢也沒有規律。同時,分別對500多個樣本劃分為200、300、420個簇,經過人工校驗,被成功聚類的樣本分別為111個、106個、105個。由此可以推斷,K-均值演算法不適合對廠商名稱的分類,分析其原因可能是每個廠商名稱所包含的詞彙量太少。接下來我們再嘗試另一種聚類演算法——層次聚類。
6. 使用層次聚類演算法
層次聚類試圖在不同的層次對資料集進行劃分,可以採用“自底向上”的聚類策略,也可以採用“自頂向下”的分拆策略。一般採用“自底向上”的策略,它的思路是先將資料集中的每個樣本看作一個初始聚類簇,然後找出兩個聚類最近的兩個簇進行合併,不斷重複該步驟,直到達到預設的聚類個數或某種條件。關鍵是如何計算兩個簇之間的距離,每個簇都是一個集合,因此需要計算集合的某種距離即可。例如,給定簇和 ,可通過以下3種方式計算距離:
- 最小距離:
- 最大距離:
- 平均距離:
最小距離由兩個簇的最近樣本決定,最大距離由兩個簇的最遠樣本決定,平均距離由兩個簇的所有樣本決定。
接下來要考慮如何確定一個合適的聚類個數或某種結束條件,具體思路是:
(1)選定一部分測試樣本,對其進行層次聚類分析。
(2)記算效能度量指標DBI和DI的變化趨勢,結合人工校驗,得到一個合適的聚類個數和對應的距離閾值。
(3)將此距離閾值作為聚類結束的條件,對所有樣本做聚類分析。此時無需再計算DBI和DI值,計算效率可以大幅提升。
# 計算兩個簇的最小距離
def distMin(dataSet1, dataSet2):
minD = 1
m = shape(dataSet1)[0]
n = shape(dataSet2)[0]
for i in range(m):
for j in range(n):
dist = gen_sim(dataSet1[i], dataSet2[j])
if dist < minD:
minD = dist
return minD
# 計算兩個簇的最大距離
def distMax(dataSet1, dataSet2):
maxD = 0
m = shape(dataSet1)[0]
n = shape(dataSet2)[0]
for i in range(m):
for j in range(n):
dist = gen_sim(dataSet1[i], dataSet2[j])
if dist > maxD:
maxD = dist
return maxD
# 計算兩個簇的評均距離
def distAvg(dataSet1, dataSet2):
avgD = 0
sumD = 0
m = shape(dataSet1)[0]
n = shape(dataSet2)[0]
for i in range(m):
for j in range(n):
dist = gen_sim(dataSet1[i], dataSet2[j])
sumD += dist
avgD = sumD / (m * n)
return avgD
# 找到距離最近的兩個簇
def findMin(M):
minDist = inf
m = shape(M)[0]
for i in range(m):
for j in range(m):
if i != j and M[i, j] < minDist:
minDist = M[i, j]
minI = i
minJ = j
return minI, minJ, minDist
# 層次聚類演算法
def hCluster(dataSet, k, dist, distMeas=distAvg):
m = shape(dataSet)[0]
clusterAssment = mat(zeros((m, 1)))
performMeasure = []
M = mat(zeros((m, m))) # 距離矩陣
# 初始化聚類簇,每個樣本作為一個類
for ii in range(m):
clusterAssment[ii, 0] = ii
for i in range(m):
for j in range(i + 1, m):
dataSeti = dataSet[nonzero(clusterAssment[:, 0].A == i)[0], :]
dataSetj = dataSet[nonzero(clusterAssment[:, 0].A == j)[0], :]
M[i, j] = distMeas(dataSeti, dataSetj)
M[j, i] = M[i, j]
if mod(i,10) == 0: print i
q = m # 設定當前聚類個數
minDist = 0
# while (q > k):
while (minDist < dist):
i, j, minDist = findMin(M) # 找到距離最小的兩個簇
# 把第j個簇歸併到第i個簇
clusterAssment[nonzero(clusterAssment[:, 0].A == j)[0], 0] = i
for l in range(j + 1, q): # 將j之後的簇重新編號
clusterAssment[nonzero(clusterAssment[:, 0].A == l)[0], 0] = l - 1
M = delete(M, j, axis=0)
M = delete(M, j, axis=1)
for l in range(q - 1): # 重新計算第i個簇和其他簇直接的距離
dataSeti = dataSet[nonzero(clusterAssment[:, 0].A == i)[0], :]
dataSetl = dataSet[nonzero(clusterAssment[:, 0].A == l)[0], :]
M[i, l] = distMeas(dataSeti, dataSetl)
M[l, i] = M[i, l]
DBI = DBIvalue(dataSet, clusterAssment, q)
DI = DIvalue(dataSet, clusterAssment, q)
performMeasure.append([q - 1, minDist, DBI, DI])
q = q - 1
print '---' + getTime() + '---'
print u'當前簇的個數是:', q
print u'距離最小的兩個簇是第%d個和第%d個,距離是%f,DBI值是%f,DI值是%f' % (
i, j, minDist, DBI, DI)
return clusterAssment, mat(performMeasure)
仍然選擇K-均值聚類分析的500多個樣本,對其進行層次聚類,得到的效能指標變化趨勢如下:
從上圖可以看出,DI值呈下降趨勢,DBI值呈階躍上升趨勢,根據效能度量的規則(DBI的值越小越好;DI的值越大越好),最優值可能出現階躍點附近,即劃分為471類和445類兩個點,同時結合人工校驗,可以確定445類更加合理。
接下來,將k值設定為445進行層次聚類分析,發現仍有少量相似的樣本被劃分到不同的類。根據業務需求,為了減少後續的核實工作量,我們希望將相似的樣本儘可能劃分到同一類中,同時可以接受少部分不同的樣本劃分到同一類,我們給予k值適當的冗餘,將其設定為420,再分別基於最大距離、最小距離、平均距離進行分析。
距離計算方法 |
聚到簇中的樣本數 |
最佳距離 |
最大距離 |
160 |
0.2975 |
最小距離 |
167 |
0.2556 |
平均距離 |
167 |
0.2839 |
從以上分類結果看出,採用層次聚類演算法對測試樣本進行分類,效果明顯優於K-均值聚類演算法。並且,該演算法可以通過學習得到距離閾值作為聚類結束的條件,從而解決了分類個數k值無法確定的問題。
為了降低個別樣本對整體結果的影響,選擇基於平均距離的距離分析演算法,並將距離閾值設定為0.29,對4574個樣本做聚類分析,最後得到3128個類,看一下部分樣本的分類效果:
四、總結
對文字聚類主要有幾個步驟:對文字分詞、構建詞袋模型、權值轉換、計算餘弦相似度、選取聚類演算法、效能度量、確定聚類結束條件。如果文字所含的詞彙量較多,並且已知分類的個數k,可以選擇二分K-均值聚類演算法。而層次聚類演算法根據樣本距離來分類,並且可以以樣本距離作為分類結束的條件,比較適合k未知的情況。
參考:
周志華《機器學習》
Peter Harrington 《機器學習實戰》