在本教程中,我會利用 Python 來說明怎樣聚類一系列的文件。我所演示的例項會識別出 top 100 電影的(來自 IMDB 列表)劇情簡介的隱藏結構。關於這個例子的詳細討論在初始版本里。本教程包括:
- 對所有劇情簡介分詞(tokenizing)和詞幹化(stemming)
- 利用 tf-idf 將語料庫轉換為向量空間(vector space)
- 計算每個文件間的餘弦距離(cosine distance)用以測量相似度
- 利用 k-means 演算法進行文件聚類
- 利用多維尺度分析(multidimensional scaling)對語料庫降維
- 利用 matplotlib 和 mpld3 繪製輸出的聚類
- 對語料庫進行Ward 聚類演算法生成層次聚類(hierarchical clustering)
- 繪製 Ward 樹狀圖(Ward dendrogram)
- 利用 隱含狄利克雷分佈(LDA) 進行主題建模
整個專案在我的 github repo 都可以找到。其中‘cluster_analysis ‘工作簿是一個完整的版本;‘cluster_analysis_web’ 為了建立教程則經過了刪減。歡迎下載程式碼並使用‘cluster_analysis’ 進行單步除錯(step through)。
如果你有任何問題,歡迎用推特來聯絡我 @brandonmrose。
在此之前,我先在前面匯入所有需要用到的庫
1 2 3 4 5 6 7 8 |
import numpy as np import pandas as pd import nltk import re import os import codecs from sklearn import feature_extraction import mpld3 |
出於走查的目的,想象一下我有 2 個主要的列表:
- ‘titles’:按照排名的影片名稱
- ‘synopses’:對應片名列表的劇情簡介
我在 github 上 po 出來的完整工作簿已經匯入了上述列表,但是為了簡潔起見,我會直接使用它們。其中最最重要的是 ‘synopses’ 列表了,‘titles’ 更多是作為了標記用的。
1 2 |
print titles[:10] #前 10 個片名 |
1 |
['The Godfather', 'The Shawshank Redemption', "Schindler's List", 'Raging Bull', 'Casablanca', "One Flew Over the Cuckoo's Nest", 'Gone with the Wind', 'Citizen Kane', 'The Wizard of Oz', 'Titanic'] |
停用詞,詞幹化與分詞
本節我將會定義一些函式對劇情簡介進行處理。首先,我載入 NLTK 的英文停用詞列表。停用詞是類似“a”,“the”,或者“in”這些無法傳達重要意義的詞。我相信除此之外還有更好的解釋。
1 2 |
# 載入 nltk 的英文停用詞作為“stopwords”變數 stopwords = nltk.corpus.stopwords.words('english') |
1 |
print stopwords[:10] |
1 |
['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your'] |
接下來我匯入 NLTK 中的 Snowball 詞幹分析器(Stemmer)。詞幹化(Stemming)的過程就是將詞打回原形。
1 2 3 |
# 載入 nltk 的 SnowballStemmer 作為“stemmer”變數 from nltk.stem.snowball import SnowballStemmer stemmer = SnowballStemmer("english") |
以下我定義了兩個函式:
- tokenize_and_stem:對每個詞例(token)分詞(tokenizes)(將劇情簡介分割成單獨的詞或詞例列表)並詞幹化
- tokenize_only: 分詞即可
我利用上述兩個函式建立了一個重要的字典,以防我在後續演算法中需要使用詞幹化後的詞(stems)。出於展示的目的,後面我又會將這些詞轉換回它們原本的的形式。猜猜看會怎樣,我實在想試試看!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# 這裡我定義了一個分詞器(tokenizer)和詞幹分析器(stemmer),它們會輸出給定文字詞幹化後的詞集合 def tokenize_and_stem(text): # 首先分句,接著分詞,而標點也會作為詞例存在 tokens = [word for sent in nltk.sent_tokenize(text) for word in nltk.word_tokenize(sent)] filtered_tokens = [] # 過濾所有不含字母的詞例(例如:數字、純標點) for token in tokens: if re.search('[a-zA-Z]', token): filtered_tokens.append(token) stems = [stemmer.stem(t) for t in filtered_tokens] return stems def tokenize_only(text): # 首先分句,接著分詞,而標點也會作為詞例存在 tokens = [word.lower() for sent in nltk.sent_tokenize(text) for word in nltk.word_tokenize(sent)] filtered_tokens = [] # 過濾所有不含字母的詞例(例如:數字、純標點) for token in tokens: if re.search('[a-zA-Z]', token): filtered_tokens.append(token) return filtered_tokens |
接下來我會使用上述詞幹化/分詞和分詞函式遍歷劇情簡介列表以生成兩個詞彙表:經過詞幹化和僅僅經過分詞後。
1 2 3 4 5 6 7 8 9 10 |
# 非常不 pythonic,一點也不! # 擴充列表後變成了非常龐大的二維(flat)詞彙表 totalvocab_stemmed = [] totalvocab_tokenized = [] for i in synopses: allwords_stemmed = tokenize_and_stem(i) #對每個電影的劇情簡介進行分詞和詞幹化 totalvocab_stemmed.extend(allwords_stemmed) # 擴充“totalvocab_stemmed”列表 allwords_tokenized = tokenize_only(i) totalvocab_tokenized.extend(allwords_tokenized) |
利用上述兩個列表,我建立了一個 pandas 的 DataFrame,以詞幹化後的詞彙表作為索引,分詞後的詞為列。這麼做便於觀察詞幹化後的詞轉換回完整的詞例。以下展示詞幹化後的詞變回原詞例是一對多(one to many)的過程:詞幹化後的“run”能夠關聯到“ran”,“runs”,“running”等等。在我看來這很棒——我非常願意將我需要觀察的詞幹化過後的詞轉換回第一個聯想到的詞例。
1 2 |
vocab_frame = pd.DataFrame({'words': totalvocab_tokenized}, index = totalvocab_stemmed) print 'there are ' + str(vocab_frame.shape[0]) + ' items in vocab_frame' |
1 |
there are 312209 items in vocab_frame |
你會注意到有些重複的地方。我可以把它清理掉,不過鑑於 DataFrame 只有 312209 項,並不是很龐大,可以用 stem-index 來觀察詞幹化後的詞。
1 |
print vocab_frame.head() |
1 2 3 4 5 6 |
words plot plot edit edit edit edit edit edit on on |
Tf-idf 與文字相似度
下面,我定義詞頻-逆向檔案頻率(tf-idf)的向量化引數,把劇情簡介列表都轉換成 tf-idf 矩陣。
為了得到 TF-IDF 矩陣,首先計算詞在文件中的出現頻率,它會被轉換成文件-詞矩陣(dtm),也叫做詞頻(term frequency)矩陣。dtm 的例子如下圖所示:
接著使用 TF-IDF 權重:某些詞在某個文件中出現頻率高,在其他文中卻不常出現,那麼這些詞具有更高的 TF-IDF 權重,因為這些詞被認為在相關文件中攜帶更多資訊。
注意我下面定義的幾個引數:
- max_df:這個給定特徵可以應用在 tf-idf 矩陣中,用以描述單詞在文件中的最高出現率。假設一個詞(term)在 80% 的文件中都出現過了,那它也許(在劇情簡介的語境裡)只攜帶非常少資訊。
- min_df:可以是一個整數(例如5)。意味著單詞必須在 5 個以上的文件中出現才會被納入考慮。在這裡我設定為 0.2;即單詞至少在 20% 的文件中出現 。因為我發現如果我設定更小的 min_df,最終會得到基於姓名的聚類(clustering)——舉個例子,好幾部電影的簡介劇情中老出現“Michael”或者“Tom”這些名字,然而它們卻不攜帶什麼真實意義。
- ngram_range:這個引數將用來觀察一元模型(unigrams),二元模型( bigrams) 和三元模型(trigrams)。參考n元模型(n-grams)。
1 2 3 4 5 6 7 8 9 10 11 |
from sklearn.feature_extraction.text import TfidfVectorizer # 定義向量化引數 tfidf_vectorizer = TfidfVectorizer(max_df=0.8, max_features=200000, min_df=0.2, stop_words='english', use_idf=True, tokenizer=tokenize_and_stem, ngram_range=(1,3)) %time tfidf_matrix = tfidf_vectorizer.fit_transform(synopses) # 向量化劇情簡介文字 print(tfidf_matrix.shape) |
1 2 3 |
CPU times: user 29.1 s, sys: 468 ms, total: 29.6 s Wall time: 37.8 s (100, 563) |
“terms” 這個變數只是 tf-idf 矩陣中的特徵(features)表,也是一個詞彙表。
1 |
terms = tfidf_vectorizer.get_feature_names() |
dist 變數被定義為 1 – 每個文件的餘弦相似度。餘弦相似度用以和 tf-idf 相互參照評價。可以評價全文(劇情簡介)中文件與文件間的相似度。被 1 減去是為了確保我稍後能在歐氏(euclidean)平面(二維平面)中繪製餘弦距離。
注意 dist 可以用以評估任意兩個或多個劇情簡介間的相似度。
1 2 |
from sklearn.metrics.pairwise import cosine_similarity dist = 1 - cosine_similarity(tfidf_matrix) |
K-means 聚類
下面開始好玩的部分。利用 tf-idf 矩陣,你可以跑一長串聚類演算法來更好地理解劇情簡介集裡的隱藏結構。我首先用 k-means 演算法。這個演算法需要先設定聚類的數目(我設定為 5)。每個觀測物件(observation)都會被分配到一個聚類,這也叫做聚類分配(cluster assignment)。這樣做是為了使組內平方和最小。接下來,聚類過的物件通過計算來確定新的聚類質心(centroid)。然後,物件將被重新分配到聚類,在下一次迭代操作中質心也會被重新計算,直到演算法收斂。
跑了幾次這個演算法以後我發現得到全域性最優解(global optimum)的機率要比區域性最優解(local optimum)大。
1 2 3 4 5 6 7 8 9 10 |
from sklearn.cluster import KMeans num_clusters = 5 km = KMeans(n_clusters=num_clusters) %time km.fit(tfidf_matrix) clusters = km.labels_.tolist() |
1 2 |
CPU times: user 232 ms, sys: 6.64 ms, total: 239 ms Wall time: 305 ms |
利用 joblib.dump pickle 模型(model),一旦演算法收斂,過載模型並分配聚類標籤(labels)。
1 2 3 4 5 6 7 8 9 |
from sklearn.externals import joblib # 註釋語句用來儲存你的模型 # 因為我已經從 pickle 載入過模型了 #joblib.dump(km, 'doc_cluster.pkl') km = joblib.load('doc_cluster.pkl') clusters = km.labels_.tolist() |
下面,我建立了一個字典,包含片名,排名,簡要劇情,聚類分配,還有電影型別(genre)(排名和型別是從 IMDB 上爬下來的)。
為了方便起見,我將這個字典轉換成了 Pandas DataFrame。我是 Pandas 的腦殘粉,我強烈建議你瞭解一下它驚豔的功能。這些我下面就會使用到,但不會深入。
1 2 3 |
films = { 'title': titles, 'rank': ranks, 'synopsis': synopses, 'cluster': clusters, 'genre': genres } frame = pd.DataFrame(films, index = [clusters] , columns = ['rank', 'title', 'cluster', 'genre']) |
1 |
frame['cluster'].value_counts() #number of films per cluster (clusters from 0 to 4) |
1 |
4 26 |
1 2 3 4 5 |
0 25 2 21 1 16 3 12 dtype: int64 |
1 2 3 |
grouped = frame['rank'].groupby(frame['cluster']) # 為了凝聚(aggregation),由聚類分類。 grouped.mean() # 每個聚類的平均排名(1 到 100) |
1 2 3 4 5 6 7 |
cluster 0 47.200000 1 58.875000 2 49.380952 3 54.500000 4 43.730769 dtype: float64 |
clusters 4 和 clusters 0 的排名最低,說明它們包含的影片在 top 100 列表中相對沒那麼棒。
在這選取 n(我選 6 個) 個離聚類質心最近的詞對聚類進行一些好玩的索引(indexing)和排列(sorting)。這樣可以更直觀觀察聚類的主要主題。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from __future__ import print_function print("Top terms per cluster:") print() # 按離質心的距離排列聚類中心,由近到遠 order_centroids = km.cluster_centers_.argsort()[:, ::-1] for i in range(num_clusters): print("Cluster %d words:" % i, end='') for ind in order_centroids[i, :6]: # 每個聚類選 6 個詞 print(' %s' % vocab_frame.ix[terms[ind].split(' ')].values.tolist()[0][0].encode('utf-8', 'ignore'), end=',') print() # 空行 print() # 空行 print("Cluster %d titles:" % i, end='') for title in frame.ix[i]['title'].values.tolist(): print(' %s,' % title, end='') print() # 空行 print() # 空行 |
聚類中的前幾項:
聚類 0 中的單詞: family, home, mother, war, house, dies,
聚類 0 中的片名: Schindler’s List, One Flew Over the Cuckoo’s Nest, Gone with the Wind, The Wizard of Oz, Titanic, Forrest Gump, E.T. the Extra-Terrestrial, The Silence of the Lambs, Gandhi, A Streetcar Named Desire, The Best Years of Our Lives, My Fair Lady, Ben-Hur, Doctor Zhivago, The Pianist, The Exorcist, Out of Africa, Good Will Hunting, Terms of Endearment, Giant, The Grapes of Wrath, Close Encounters of the Third Kind, The Graduate, Stagecoach, Wuthering Heights,
聚類 1 中的單詞: police, car, killed, murders, driving, house,
聚類 1 中的片名: Casablanca, Psycho, Sunset Blvd., Vertigo, Chinatown, Amadeus, High Noon, The French Connection, Fargo, Pulp Fiction, The Maltese Falcon, A Clockwork Orange, Double Indemnity, Rebel Without a Cause, The Third Man, North by Northwest,
聚類 2 中的單詞: father, new, york, new, brothers, apartments,
聚類 2 中的片名: The Godfather, Raging Bull, Citizen Kane, The Godfather: Part II, On the Waterfront, 12 Angry Men, Rocky, To Kill a Mockingbird, Braveheart, The Good, the Bad and the Ugly, The Apartment, Goodfellas, City Lights, It Happened One Night, Midnight Cowboy, Mr. Smith Goes to Washington, Rain Man, Annie Hall, Network, Taxi Driver, Rear Window,
聚類 3 中的單詞: george, dance, singing, john, love, perform,
聚類 3 中的片名: West Side Story, Singin’ in the Rain, It’s a Wonderful Life, Some Like It Hot, The Philadelphia Story, An American in Paris, The King’s Speech, A Place in the Sun, Tootsie, Nashville, American Graffiti, Yankee Doodle Dandy,
聚類 4 中的單詞: killed, soldiers, captain, men, army, command,
聚類 4 中的片名: The Shawshank Redemption, Lawrence of Arabia, The Sound of Music, Star Wars, 2001: A Space Odyssey, The Bridge on the River Kwai, Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb, Apocalypse Now, The Lord of the Rings: The Return of the King, Gladiator, From Here to Eternity, Saving Private Ryan, Unforgiven, Raiders of the Lost Ark, Patton, Jaws, Butch Cassidy and the Sundance Kid, The Treasure of the Sierra Madre, Platoon, Dances with Wolves, The Deer Hunter, All Quiet on the Western Front, Shane, The Green Mile, The African Queen, Mutiny on the Bounty,
多維尺度分析(Multidimensional scaling)
利用下面多維尺度分析(MDS)的程式碼將距離矩陣轉化為一個二維陣列。我並不想假裝我很瞭解MDS,不過這個演算法很管用。另外可以用 特徵降維(principal component analysis) 來完成這個任務。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import os # 為了使用 os.path.basename 函式 import matplotlib.pyplot as plt import matplotlib as mpl from sklearn.manifold import MDS MDS() # 將二位平面中繪製的點轉化成兩個元素(components) # 設定為“precomputed”是因為我們提供的是距離矩陣 # 我們可以將“random_state”具體化來達到重複繪圖的目的 mds = MDS(n_components=2, dissimilarity="precomputed", random_state=1) pos = mds.fit_transform(dist) # 形如 (n_components, n_samples) xs, ys = pos[:, 0], pos[:, 1] |
視覺化文件聚類
本節中,我會演示怎樣利用 matplotlib 和 mpld3(將 matplotlib 封裝成 D3.js)來實現文件聚類的視覺化。
首先,我定義了一些字典,讓聚類的編號和聚類繪色,聚類名稱一一對應。其中聚類對應的名稱是從離聚類質心最近的單詞中挑選出來的。
1 2 3 4 5 6 7 8 9 |
# 用字典設定每個聚類的顏色 cluster_colors = {0: '#1b9e77', 1: '#d95f02', 2: '#7570b3', 3: '#e7298a', 4: '#66a61e'} # 用字典設定每個聚類名稱 cluster_names = {0: 'Family, home, war', 1: 'Police, killed, murders', 2: 'Father, New York, brothers', 3: 'Dance, singing, love', 4: 'Killed, soldiers, captain'} |
下面我會用 matplotlib 來繪製彩色的帶標籤的觀測物件(影片,片名)。關於 matplotlib 繪圖我不想討論太多,但我儘可能提供一些有用的註釋。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# 在 ipython 中內聯(inline)演示 matplotlib 繪圖 %matplotlib inline # 用 MDS 後的結果加上聚類編號和繪色建立 DataFrame df = pd.DataFrame(dict(x=xs, y=ys, label=clusters, title=titles)) # 聚類歸類 groups = df.groupby('label') # 設定繪圖 fig, ax = plt.subplots(figsize=(17, 9)) # 設定大小 ax.margins(0.05) # 可選項,只新增 5% 的填充(padding)來自動縮放(auto scaling)。 # 對聚類進行迭代並分佈在繪圖上 # 我用到了 cluster_name 和 cluster_color 字典的“name”項,這樣會返回相應的 color 和 label for name, group in groups: ax.plot(group.x, group.y, marker='o', linestyle='', ms=12, label=cluster_names[name], color=cluster_colors[name], mec='none') ax.set_aspect('auto') ax.tick_params( axis= 'x', # 使用 x 座標軸 which='both', # 同時使用主刻度標籤(major ticks)和次刻度標籤(minor ticks) bottom='off', # 取消底部邊緣(bottom edge)標籤 top='off', # 取消頂部邊緣(top edge)標籤 labelbottom='off') ax.tick_params( axis= 'y', # 使用 y 座標軸 which='both', # 同時使用主刻度標籤(major ticks)和次刻度標籤(minor ticks) left='off', # 取消底部邊緣(bottom edge)標籤 top='off', # 取消頂部邊緣(top edge)標籤 labelleft='off') ax.legend(numpoints=1) # 圖例(legend)中每項只顯示一個點 # 在座標點為 x,y 處新增影片名作為標籤(label) for i in range(len(df)): ax.text(df.ix[i]['x'], df.ix[i]['y'], df.ix[i]['title'], size=8) plt.show() # 展示繪圖 # 以下注釋語句可以儲存需要的繪圖 #plt.savefig('clusters_small_noaxes.png', dpi=200) |
1 |
plt.close() |
繪製的聚類分佈圖看起來不錯,但是重疊在一起的標籤真是亮瞎了眼。因為之前使用過 D3.js,所以我知道有個解決方案是基於瀏覽器和 javascript 互動的。所幸我最近偶然發現了 mpld3,是基於 matplotlib 的 D3 封裝。Mpld3 主要可以讓你使用 matplotlib 的語法實現網頁互動。它非常容易上手,當你遇到感興趣的內容,滑鼠停駐的時候,利用高效的介面可以新增氣泡提示。
另外,它還提供了縮放和拖動這麼炫的功能。以下的 javascript 片段主要自定義了縮放和拖動的位置。別太擔心,實際上你用不到它,但是稍後匯出到網頁的時候有利於格式化。你唯一想要改變的應該是藉助 x 和 y 的 attr 來改變工具欄的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# 自定義工具欄(toolbar)位置 class TopToolbar(mpld3.plugins.PluginBase): """移動工具欄到分佈圖頂部的外掛""" JAVASCRIPT = """ mpld3.register_plugin("toptoolbar", TopToolbar); TopToolbar.prototype = Object.create(mpld3.Plugin.prototype); TopToolbar.prototype.constructor = TopToolbar; function TopToolbar(fig, props){ mpld3.Plugin.call(this, fig, props); }; TopToolbar.prototype.draw = function(){ // 還缺少工具欄 svg,因此一開始要繪製 this.fig.toolbar.draw(); // 接著把 y 的位置變為圖頂部 this.fig.toolbar.toolbar.attr("x", 150); this.fig.toolbar.toolbar.attr("y", 400); // 再移除 draw 函式,防止被呼叫 this.fig.toolbar.draw = function() {} } """ def __init__(self): self.dict_ = {"type": "toptoolbar"} |
下面是對於互動式散點圖的實際操作。我同樣不會深入這個問題因為是直接從 mpld3 的例程移植過來的。雖然我用 pandas 對聚類進行了歸類,但它們一一迭代後會分佈在散點圖上。和原生 D3 相比,用 mpld3 來做這項工作並且嵌入到 python 的工作簿中簡單多了。如果你看了我網站上的其它內容,你就知道我有多麼愛 D3 了。但以後一些基本的互動我可能還是會用 mpld3。
記住 mpld3 還可以自定義 CSS,像我設計的字型,座標軸還有散點圖左邊的間距(margin)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
# 用 MDS 後的結果加上聚類編號和繪色建立 DataFrame df = pd.DataFrame(dict(x=xs, y=ys, label=clusters, title=titles)) # 聚類歸類 groups = df.groupby('label') # 自定義 css 對字型格式化以及移除座標軸標籤 css = """ text.mpld3-text, div.mpld3-tooltip { font-family:Arial, Helvetica, sans-serif; } g.mpld3-xaxis, g.mpld3-yaxis { display: none; } svg.mpld3-figure { margin-left: -200px;} """ # 繪圖 fig, ax = plt.subplots(figsize=(14,6)) # 設定大小 ax.margins(0.03) # 可選項,只新增 5% 的填充(padding)來自動縮放 # 對聚類進行迭代並分佈在繪圖上 # 我用到了 cluster_name 和 cluster_color 字典的“name”項,這樣會返回相應的 color 和 label for name, group in groups: points = ax.plot(group.x, group.y, marker='o', linestyle='', ms=18, label=cluster_names[name], mec='none', color=cluster_colors[name]) ax.set_aspect('auto') labels = [i for i in group.title] # 用點來設定氣泡訊息,標籤以及已經定義的“css” tooltip = mpld3.plugins.PointHTMLTooltip(points[0], labels, voffset=10, hoffset=10, css=css) # 將氣泡訊息與散點圖聯絡起來 mpld3.plugins.connect(fig, tooltip, TopToolbar()) # 隱藏刻度線(tick marks) ax.axes.get_xaxis().set_ticks([]) ax.axes.get_yaxis().set_ticks([]) # 隱藏座標軸 ax.axes.get_xaxis().set_visible(False) ax.axes.get_yaxis().set_visible(False) ax.legend(numpoints=1) # 圖例中每項只顯示一個點 mpld3.display() # 展示繪圖 # 以下注釋語句可以輸出 html #html = mpld3.fig_to_html(fig) #print(html) |
(譯者按:因為無法插入 js,所以對原 post 截圖)
文件層次聚類
到目前為止我已經成功用 k-means 演算法將文件聚類並繪製了結果,下面我想嘗試其它聚類演算法。我選擇了 Ward 聚類演算法 ,因為它可以進行層次聚類。Ward 聚類屬於凝聚(agglomerative)聚類演算法,亦即在每個處理階段,聚類間兩點距離最小的會被合併成一個聚類。我用之前計算得到的餘弦距離矩陣(dist)來計算 linkage_matrix,等會我會把它繪製在樹狀圖中。
值得注意的是這個演算法返回了 3 組主要的聚類,最大聚類又被分成了 4 個主要的子聚類。其中紅色標註的聚類包含了多部“Killed, soldiers, captain”主題下的影片。Braveheart 和 Gladiator* 是我最喜歡的兩部片子,它們都在低層(low-level)的聚類裡。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
from scipy.cluster.hierarchy import ward, dendrogram linkage_matrix = ward(dist) # 聚類演算法處理之前計算得到的距離,用 linkage_matrix 表示 fig, ax = plt.subplots(figsize=(15, 20)) # 設定大小 ax = dendrogram(linkage_matrix, orientation="right", labels=titles); plt.tick_params( axis= 'x', # 使用 x 座標軸 which='both', # 同時使用主刻度標籤(major ticks)和次刻度標籤(minor ticks) bottom='off', # 取消底部邊緣(bottom edge)標籤 top='off', # 取消頂部邊緣(top edge)標籤 labelbottom='off') plt.tight_layout() # 展示緊湊的繪圖佈局 # 註釋語句用來儲存圖片 plt.savefig('ward_clusters.png', dpi=200) # 儲存圖片為 ward_clusters |
1 |
plt.close() |
隱含狄利克雷分佈
本節的重點放在如何利用 隱含狄利克雷分佈(LDA)發掘 top 100 影片劇情簡介中的隱藏結構。LDA 是概率主題模型(probabilistic topic model),即假定文件由許多主題(topics)組成,而文件中的每個單詞都可以歸入某個主題。這兒有篇高大上的概論(overview),是關於概率主題模型的,作者是領域內的大牛之一——David Blei,在這裡可以下載 Communications of the ACM。另外,Blei 也是 LDA 論文作者之一。
在這裡我用 Gensim 包 來實現 LDA。其中劇情簡介的預處理會有些不一樣。我首先定義了個函式把專有名詞給去掉。
1 2 3 4 5 6 |
# 去除文字中所有的專有名詞……不幸的是,現在句子的第一個單詞也被去掉了。 import string def strip_proppers(text): # 首先分句,接著分詞,而標點也會作為詞例存在 tokens = [word for sent in nltk.sent_tokenize(text) for word in nltk.word_tokenize(sent) if word.islower()] return "".join([" "+i if not i.startswith("'") and i not in string.punctuation else i for i in tokens]).strip() |
因為上述函式功能實現基於大寫的特性,很容易就把句子首個單詞也去掉了。所以我又寫了下面的這個函式,用到了 NLTK 的詞性標註器。然而,讓所有劇情簡介跑這個函式耗時太長了,所以我還是決定繼續用回上述的函式。
1 2 3 4 5 6 7 |
# 去除文字中所有專有名詞(NNP)和複數名詞(NNPS) from nltk.tag import pos_tag def strip_proppers_POS(text): tagged = pos_tag(text.split()) # 使用 NLTK 的詞性標註器 non_propernouns = [word for word,pos in tagged if pos != 'NNP' and pos != 'NNPS'] return non_propernouns |
現在我要對真正的文字(去除了專有名詞,經過分詞,以及去除了停用詞)進行處理了。
1 2 3 4 5 6 7 8 9 10 11 12 |
from gensim import corpora, models, similarities # 去除專有名詞 %time preprocess = [strip_proppers(doc) for doc in synopses] # 分詞 %time tokenized_text = [tokenize_and_stem(text) for text in preprocess] # 去停用詞 %time texts = [[word for word in text if word not in stopwords] for text in tokenized_text] |
1 2 3 4 5 6 |
CPU times: user 12.9 s, sys: 148 ms, total: 13 s Wall time: 15.9 s CPU times: user 15.1 s, sys: 172 ms, total: 15.3 s Wall time: 19.3 s CPU times: user 4.56 s, sys: 39.2 ms, total: 4.6 s Wall time: 5.95 s |
下面我用 Gensim 進行特有的轉化; 我把一些極端(extreme)的單詞也給去掉了(詳情見內部註釋)。
1 2 3 4 5 6 7 8 |
# 用文字構建 Gensim 字典 dictionary = corpora.Dictionary(texts) # 去除極端的詞(和構建 tf-idf 矩陣時用到 min/max df 引數時很像) dictionary.filter_extremes(no_below=1, no_above=0.8) # 將字典轉化為詞典模型(bag of words)作為參考 corpus = [dictionary.doc2bow(text) for text in texts] |
下面執行實際模型。我將 passes 設定為 100 來保證收斂,但你可以看到我的機器花了 13 分鐘來完成這些。因為我將文字分得太細,所以基本上每步(pass)都會用到所有劇情簡介。我應該繼續優化這個問題。Gensim 支援並行(parallel)運算,當我處理更大的語料庫時,我非常樂意進行深入探索。
1 2 3 4 5 6 |
%time lda = models.LdaModel(corpus, num_topics=5, id2word=dictionary, update_every=5, chunksize=10000, passes=100) |
1 2 |
CPU times: user 9min 53s, sys: 5.87 s, total: 9min 59s Wall time: 13min 1s |
每個主題都由一系列的詞定義,連同一定的概率。
1 |
lda.show_topics() |
1 2 3 4 5 |
[u'0.006*men + 0.005*kill + 0.004*soldier + 0.004*order + 0.004*patient + 0.004*night + 0.003*priest + 0.003*becom + 0.003*new + 0.003*speech', u"0.006*n't + 0.005*go + 0.005*fight + 0.004*doe + 0.004*home + 0.004*famili + 0.004*car + 0.004*night + 0.004*say + 0.004*next", u"0.005*ask + 0.005*meet + 0.005*kill + 0.004*say + 0.004*friend + 0.004*car + 0.004*love + 0.004*famili + 0.004*arriv + 0.004*n't", u'0.009*kill + 0.006*soldier + 0.005*order + 0.005*men + 0.005*shark + 0.004*attempt + 0.004*offic + 0.004*son + 0.004*command + 0.004*attack', u'0.004*kill + 0.004*water + 0.004*two + 0.003*plan + 0.003*away + 0.003*set + 0.003*boat + 0.003*vote + 0.003*way + 0.003*home'] |
下面,我將每個主題轉換成了包含前 20 個詞的詞彙表。當我使用 k-means 演算法得出的 war/family 主題和更清晰的 war/epic 主題比較,你可以觀察主題分解後的相似性。
1 2 3 4 5 6 |
topics_matrix = lda.show_topics(formatted=False, num_words=20) topics_matrix = np.array(topics_matrix) topic_words = topics_matrix[:,:,1] for i in topic_words: print([str(word) for word in i]) |
1 2 3 4 5 6 7 8 9 |
['men', 'kill', 'soldier', 'order', 'patient', 'night', 'priest', 'becom', 'new', 'speech', 'friend', 'decid', 'young', 'ward', 'state', 'front', 'would', 'home', 'two', 'father'] ["n't", 'go', 'fight', 'doe', 'home', 'famili', 'car', 'night', 'say', 'next', 'ask', 'day', 'want', 'show', 'goe', 'friend', 'two', 'polic', 'name', 'meet'] ['ask', 'meet', 'kill', 'say', 'friend', 'car', 'love', 'famili', 'arriv', "n't", 'home', 'two', 'go', 'father', 'money', 'call', 'polic', 'apart', 'night', 'hous'] ['kill', 'soldier', 'order', 'men', 'shark', 'attempt', 'offic', 'son', 'command', 'attack', 'water', 'friend', 'ask', 'fire', 'arriv', 'wound', 'die', 'battl', 'death', 'fight'] ['kill', 'water', 'two', 'plan', 'away', 'set', 'boat', 'vote', 'way', 'home', 'run', 'ship', 'would', 'destroy', 'guilti', 'first', 'attack', 'go', 'use', 'forc'] |
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式