一:引導
目錄
搭檔鎮樓。
今天的頭版給我漂亮的搭檔,啥年芳二六、待字閨中之類的矯情話就不說了,希望看到文章的小夥子,如果對眼,請放下你手中的遊戲,我可以牽線搭橋。
好好相愛,就是爲民除害。
搭檔是重慶妹紙,重慶妹紙長得是很水靈。
搭檔給我的感覺是情商比較高,比較會捧哏,說話不會悶。
搭檔身高160體重100,學歷本科水瓶座,目前在重慶的銀行工作。
以下爲搭檔的自我介紹:
性格慢熱,絕不隨便。
心裏住着公主,也住着女王。
能喫苦下田種地,也能溫書習字梳妝。
喜歡跳傘游泳潛水衝浪,就差一個男生一起去體驗。
家裏兩房一車,父母事業單位就職,性格開明。
希望男生乾淨、上進,五官端正,性格開朗健談,收入穩定能養家,家庭和諧,會做飯更佳。
二:內容預告
最近公司要做英語新聞推薦系統,需要對新聞內容做分析,主要包括網頁去重、實體抽取和關鍵詞提取。
提到關鍵詞提取,大家很容易就能想到TF-IDF和TextRank。這兩種方法可以分別通過調sklearn和jiabe的包來實現。
可如果要提取的是幾個單詞組成的短語呢?
這時候按標準的調包教程來做,是難以滿足業務需要的。
所以本文探索,如何把TF-IDF和n-gram結合,用來提取短語。
本文關注以下三個問題:
-
關鍵詞提取有哪些方法?
-
如何把TF-IDF和n-gram結合?
-
如何根據詞性和停用詞過濾?
三:關鍵詞提取的方法
01 TF-IDF
TF-IDF在nlp領域無人不知無人不曉,思想簡單卻有效,榮獲nlp界的諾貝爾獎:奧卡姆剃刀獎。
TF(term frequency),即詞頻,用來衡量詞在一篇文檔中的重要性,詞頻越高,越重要。計算公式爲:
某文檔中某詞出現的次數該文檔的總詞數
IDF((inverse document frequency),叫做逆文檔頻率,衡量某個詞在所有文檔集合中的常見程度。
當包含某個詞的文檔的數量越多時,這個詞也就爛大街了,重要性越低。計算公式爲:
全部文檔的數量包含某詞的文檔的數量
於是TF-IDF = TF * IDF, 它表明字詞的重要性與它在某篇文檔中出現的次數成正比,與它在所有文檔中出現的次數成反比。
使用TF-IDF的一個假設前提是:已經去掉了停用詞。
TF-IDF的優點是計算速度快,結果穩健。
需要輸入多篇文檔,可以輸出每篇文檔的關鍵詞。
02.TextRank
TextRank基於圖計算來提取關鍵詞,需要進行迭代,速度比TF-IDF慢,但不需要通過輸入多篇文檔來提取關鍵詞。
TextRank是把一篇文檔構建成無向圖,圖中的節點就是詞語,圖上的邊就是共現詞之間的連接。
共現詞通過滑動窗口來確定,共現詞之間用邊相連,而邊上的權重可以使用共現詞的相似度。
jieba提供了用TextRank提取關鍵詞的函數,但是邊的權重是詞共現的頻率。
這樣做其實比較粗糙,我們可以使用基於詞向量計算的相似度得分作爲權重,來進行迭代。
我這不知不覺又給自己安排了任務,文章名字都想好了:
《TextRank提取關鍵詞:我也是改過jieba源碼的人!》
TextRank也需要先去掉停用詞。
03.文本聚類法
TF-IDF只從淺層的詞頻角度挖掘關鍵詞,而通過Kmeans或Topic Model,可以從深層的隱含語義角度來提取關鍵詞。
一種方法是通過Kmeans來提取,使用詞向量作爲特徵。
比如對於一篇文檔,我們要提取10個關鍵詞。
那麼可以把文檔中的詞聚成5類,然後取每個類中,與類中心最近的2個點,作爲關鍵詞。
也可以直接聚成10類,取和類中心最近的詞。
另一種方法是通過Topic Model來提取,比如LSA和LDA,使用詞頻矩陣作爲特徵。
比如對於包含多篇文檔的單領域語料,我們要挖掘關鍵詞,整理詞庫。
那麼可以用LDA進行聚類,得到每個主題的單詞分佈,再取出每個主題下排名靠前的topk個單詞,或者權重高於某個閾值的單詞,構成關鍵詞庫。
主題的單詞分佈爲:
(0, '0.025*"基金" + 0.020*"分紅" + 0.007*"中" + 0.006*"考試" + 0.006*"私募" + 0.005*"公司" + 0.004*"採用" + 0.004*"市場" + 0.004*"遊戲" + 0.004*"元"')
(1, '0.007*"套裝" + 0.007*"中" + 0.006*"設計" + 0.004*"元" + 0.004*"拍攝" + 0.003*"萬" + 0.003*"市場" + 0.003*"編輯" + 0.003*"時尚" + 0.003*"穿"')
(2, '0.007*"英寸" + 0.005*"中" + 0.004*"中國" + 0.003*"拍攝" + 0.003*"比賽" + 0.002*"高清" + 0.002*"小巧" + 0.002*"產品" + 0.002*"億股" + 0.002*"機型"')
(3, '0.082*"基金" + 0.015*"公司" + 0.014*"市場" + 0.014*"投資" + 0.009*"股票" + 0.007*"億元" + 0.007*"中" + 0.006*"收益" + 0.006*"行業" + 0.006*"一季度"')
...
04.有監督的關鍵詞提取
有監督的方法需要有標註的數據,我沒有嘗試過。
看一些文章說可以轉化爲統計機器翻譯(SMT)的問題,轉化爲序列標註(NER)的問題,或者轉化爲詞語排序(LTR)的問題。
我只理解了轉化爲序列標註問題的做法,這個和用深度學習做文本摘要類似。
文檔中的詞語,如果爲關鍵詞,則標註爲1,否則標註爲0,也就是對一個詞進行二分類。
據說效果比上述無監督的方法好。
四:TF-IDF+n-gram提取關鍵詞
提取單詞作爲關鍵詞,比較容易實現,如果要提取短語呢?
我嘗試了開源工具RAKE,它是根據停用詞來劃分句子,再提取短語的。
使用之後,我發現RAKE存在兩個問題:
一是提取的短語有些長達4-5個單詞,這顯然不合適;
二是沒有根據詞性進行過濾。
當然,我們可以用RAKE得到一個粗糙的結果,然後再做細緻的處理,比如根據包含單詞的數量、根據詞性模板等進行過濾。
不過RAKE的代碼寫得實在太亂了,我沒有耐心看下去,對其原理也不太瞭解,也就沒加工再利用。
我給出的方案是TF-IDF結合n-gram來提取關鍵短語,並根據單詞長度、停用詞和詞性進行過濾。
01.英文新聞測試語料
我去某網站下載了4篇英語新聞,對於其中的一篇,經過增加一段和兩段、刪除一段和兩段的操作,得到4篇內容高度重合的新聞,最終得到8篇英語新聞。
├── english_new_1.txt
├── english_new_2.txt
├── english_new_3.txt
├── english_new_add_1.txt # 增加一段
├── english_new_add_2.txt # 增加兩段
├── english_new_base.txt # 原始新聞
├── english_new_remove_1.txt # 刪除一段
└── english_new_remove_2.txt # 刪除兩段
首先用SimHash去重,保留4篇英語新聞(爲了測試SimHash)。
02.需要的庫
TF-IDF的計算,用sklearn的包。
由於是英文文本的處理,所以需要用NLTK做英文單詞拆分、詞性標註和詞形還原。
#coding:utf-8
import os,re
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
from itertools import chain
from nltk import pos_tag, word_tokenize,sent_tokenize
from nltk.stem import WordNetLemmatizer
""" 一:初始化詞形還原的類 """
lemmatizer = WordNetLemmatizer()
爲什麼需要做詞形還原呢?
詞形還原是指把英文的單詞,從複數形態、第三人稱形態等複雜形態,轉換爲最基礎的形態,比如 salaries 還原爲salary,makes還原爲make。
如果我們提取的關鍵詞是單個詞,那麼在使用TF-IDF進行提取之前,先要對每個單詞做詞形還原,不然salaries和salary會被認爲是不同的兩個單詞。
詞形還原是基於詞典的,準確率比較高,所以使用NLTK做詞形還原時,需要下載數據 WordNet。
在公司很容易出現網絡不通的問題,反正我是下載不了。
nltk.download('wordnet')
[nltk_data] Error loading wordnet: <urlopen error [Errno 104]
[nltk_data] Connection reset by peer>
False
其實不止詞形還原需要下載數據,做詞性標註和實體識別,都需要下載,特別麻煩。
於是我乾脆把NLTK的所有數據文件都下載了,文件大小爲1.08G,解壓後放到相應的路徑,就一勞永逸了。
下載鏈接:
https://pan.baidu.com/s/1Ms4tfGlF3IA6F0Mg5Ljd8Q
提取碼:
07qb
下載好後解壓,在ubuntu環境下,給文件賦予相應的可執行權限(Goup和Others也需要可執行權限),然後把數據文件複製到以下路徑:
Searched in:
- '/opt/anaconda3/nltk_data'
- '/opt/anaconda3/share/nltk_data'
- '/opt/anaconda3/lib/nltk_data'
- '/usr/share/nltk_data'
- '/usr/local/share/nltk_data'
- '/usr/lib/nltk_data'
- '/usr/local/lib/nltk_data'
03.對新聞做單詞拆分
最好不要對整篇文檔做 word_tokenize,而是先 sent_tokenize(劃分句子),再word_tokenize。
因爲實體識別、詞性標註和句法分析,最好以句子爲單位來做。
""" 一:對文檔進行分詞/拆字 """
def tokenize_doc(docs):
"""
:params: docs——多篇文檔
"""
docs_tokenized = []
for doc in docs:
doc = re.sub('[\n\r\t]+',' ',doc)
""" 1:分句 """
sents = sent_tokenize(doc)
""" 2:單詞拆分 """
sents_tokenized = [word_tokenize(sent.strip()) for sent in sents]
docs_tokenized.append(sents_tokenized)
return docs_tokenized
04.n-gram的生成
sklearn的TfidfVectorizer的類裏邊,也提供了n-gram的功能,那爲什麼我還要自己生成呢?
原因是sklearn內置的功能裏,生成n-gram後,就直接計算TF-IDF了,沒根據停用詞和詞性,過濾n-gram。
而根據停用詞和詞性過濾n-gram,必須在計算TF-IDF之前。
那在tokenize的時候做過濾不就得了嗎?
不行!
過濾n-gram,是指如果n-gram中,有一個單詞是停用詞,或者有一個單詞的詞性是過濾的詞性,那麼整個n-gram都需要去掉,而不能只去掉該單詞。
在tokenize時過濾單詞,那麼生成的n-gram短語塊可能是錯誤的。
""" 二:產生n-gram,用於提取短語塊 """
def gene_ngram(sentence,n=3,m=2):
"""
----------
sentence: 分詞後的句子
n: 取3,則爲3-gram
m: 取1,則保留1-gram
----------
"""
if len(sentence) < n:
n = len(sentence)
ngrams = [sentence[i-k:i] for k in range(m, n+1) for i in range(k, len(sentence)+1)]
return ngrams
05.n-gram的過濾
生成的trigram如下:
[['What', 'are', 'the'], ['are', 'the', 'legal'], ['the', 'legal', 'implications'], ['legal', 'implications', 'of'],
我們要對n-gram進行過濾,根據停用詞、詞性、單詞的長度以及是否包含數字,來過濾。
詞性可以去網上找英文詞性對照表。
如果n-gram中,包含長度爲1的單詞,那麼過濾掉。
""" n-gram中是否有單詞長度爲1 """
def clean_by_len(gram):
for word in gram:
if len(word) < 2:
return False
return True
""" 三:按停用詞表和詞性,過濾單詞 """
def clean_ngrams(ngrams):
"""
:params: ngrams
"""
stopwords = open("./百度英文停用詞表.txt",encoding='utf-8').readlines()
stopwords = [word.strip() for word in stopwords]
pat = re.compile("[0-9]+")
""" 如果n-gram中有停用詞,則去掉 """
ngrams = [gram for gram in ngrams if len(set(stopwords).intersection(set(gram)))==0]
""" 如果n-gram中有數字,則去掉 """
ngrams = [gram for gram in ngrams if len(pat.findall(''.join(gram).strip()))==0]
""" n-gram中有單詞長度爲1,則去掉 """
ngrams = [gram for gram in ngrams if clean_by_len(gram)]
""" 只保留名詞、動詞和形容詞 """
allow_pos_one = ["NN","NNS","NNP","NNPS"]
allow_pos_two = ["NN","NNS","NNP","NNPS","JJ","JJR","JJS"]
allow_pos_three = ["NN","NNS","NNP","NNPS","VB","VBD","VBG","VBN","VBP","VBZ","JJ","JJR","JJS"]
ngrams_filter = []
for gram in ngrams:
words,pos = zip(*pos_tag(gram))
""" 如果提取單詞作爲關鍵詞,則必須爲名詞 """
if len(words) == 1:
if not pos[0] in allow_pos_one:
continue
ngrams_filter.append(gram)
else:
""" 如果提取短語,那麼開頭必須爲名詞、動詞、形容詞,結尾爲名詞 """
if not (pos[0] in allow_pos_three and pos[-1] in allow_pos_one):
continue
ngrams_filter.append(gram)
return ngrams_filter
06.計算TF-IDF
用上面的函數,完成生成n-gram、過濾n-gram的步驟,然後把n-gram之間的單詞用下劃線連接:"_" ,n-gram之間用空格連接。
""" 1:處理爲n-gram,n_=2或3 """
docs_ngrams = [gene_ngram(doc,n=n_,m=n_) for doc in docs_tokenized]
""" 2: 按停用詞表和詞性,過濾 """
docs_ngrams = [clean_ngrams(doc) for doc in docs_ngrams]
docs_ = []
for doc in docs_ngrams:
docs_.append(' '.join(['_'.join(ngram) for ngram in doc]))
得到的n-gram如下:
['face_tough_times Hiring_activity_declines reveals_Naukri_JobSpeak health_system_preparedness micro-delivery_startup_DailyNinja ...]
爲什麼n-gram之間的單詞用下劃線來連接呢?
我試了其他的符號:#、=,但是發現送入sklearn的包裏計算時,n-gram會被重新拆分爲單詞,vocab不是n-gram短語,而是單詞。
我估計是因爲python中,下劃線比較特殊,如正則表達式 \w,表示字母、數字和下劃線,其他的符號則容易被視爲文本噪音而去掉。
接着計算n-gram的TF-IDF,這裏又有一個坑:如果不自己指定n-gram字典,那麼sklearn自己構建的字典中,可能會有單詞或單詞加下劃線這種奇怪的東西。
所以需要自己傳入vocab。
""" 四:獲取 tf-idf 特徵 """
def calcu_tf_idf(documents):
"""
:param: data爲列表格式的文檔集合, 計算 tf_idf 特徵
"""
""" 指定vocab,否則n-gram的計算會出錯 """
vocab = set(chain.from_iterable([doc.split() for doc in documents]))
vec = TfidfVectorizer(vocabulary=vocab)
D = vec.fit_transform(documents)
voc = dict((i, w) for w, i in vec.vocabulary_.items())
features = {}
for i in range(D.shape[0]):
Di = D.getrow(i)
features[i] = list(zip([voc[j] for j in Di.indices], Di.data))
return features
07.完成關鍵詞提取
接着,就可以完成關鍵詞的提取了。
考慮到關鍵詞提取的全面性,分別提取unigram、bigram和trigram關鍵詞。
如果提取unigram關鍵詞,那麼需要做詞形還原。
def get_ngram_keywords(docs_tokenized,topk=5,n_=2):
""" 1:處理爲n-gram """
docs_ngrams = [gene_ngram(doc,n=n_,m=n_) for doc in docs_tokenized]
""" 2: 按停用詞表和詞性,過濾 """
docs_ngrams = [clean_ngrams(doc) for doc in docs_ngrams]
docs_ = []
for doc in docs_ngrams:
docs_.append(' '.join(['_'.join(ngram) for ngram in doc]))
""" 3: 計算tf-idf,提取關鍵詞 """
features = calcu_tf_idf(docs_)
docs_keys = []
for i,pair in features.items():
topk_idx = np.argsort([v for w,v in pair])[::-1][:topk]
docs_keys.append([pair[idx][0] for idx in topk_idx])
return [[' '.join(words.split('_')) for words in doc ]for doc in docs_keys]
""" 五:抽取n-gram關鍵詞 """
def get_keywords(docs_tokenized,topk):
""" 1: 英文單詞拆分 """
docs_tokenized = [list(chain.from_iterable(doc)) for doc in docs_tokenized]
""" 2: 提取關鍵詞,包括unigram,bigram和trigram """
docs_keys = []
for n in [1,2,3]:
if n == 1:
""" 3: 如果是unigram,還需要做詞形還原 """
docs_tokenized = [[lemmatizer.lemmatize(word) for word in doc] for doc in docs_tokenized]
keys_ngram = get_ngram_keywords(docs_tokenized, topk,n_=n)
docs_keys.append(keys_ngram)
return [uni+bi+tri for uni,bi,tri in zip(*docs_keys)]
ok,來看關鍵詞提取的結果。
選取其中一篇新聞,關於新冠肺炎禁閉防範期間,降薪和裁員的影響。
""" 標題:在冠狀病毒禁閉期間裁員或減薪有什麼法律影響 """
What are the legal implications of layoffs or salary cuts during coronavirus lockdown
以下是提取的unigram、bigram和trigram關鍵詞。
從關鍵詞中,大致可以看出是關於降薪、削減成本、勞動者保護、公司決策。
遺憾的是,冠狀病毒(Coronavirus)這個詞沒有提取出來,但是通過實體識別抽取了出來。
'keywords': ['employee', # 僱員
'order', # 訂貨
'employer', # 僱主
'cut', # 削減
'lockdown', # 一級防範禁閉(期)
'scenario', # 方案
'time', # 時期
'salary', # 薪酬
'organisation', # 組織
'startup', # 創業公司
'cost reduction', # 成本削減
'legal implications', # 法律影響
'salary cuts', # 降薪
'employer beware', # 僱主品牌
'discretionary spending', # 可自由支配的個人開支
'activity declines', # 活動減少
'recommended philosophy', # 被推薦的方式
'practice group', # 業務部門
'population density', # 人口密度
'pay scales', # 工資標準
'advising startup founders', # 建議創業者
'broader startup ecosystem', # 更廣泛的創業生態系統
'employers make payment', # 僱主支付
'ensure legal protection', # 確保法律保護
'face tough times', # 面臨艱難時期
'garner sufficient caution', # 獲得足夠的關注
'health system preparedness', # 衛生系統的準備
'lower pay scales', # 更低的工資標準
'seek legal advice', # 尋求法律諮詢
'top management level'] # 公司高層
時間緊,關鍵詞提取做得還不夠深入,沒有嘗試更多方法。
寫這篇文章,是爲了整理思路,希望有小夥伴可以一起交流有效的提取短語的方法。