TF-IDF提取关键词 实验介绍
[!question] Task 1 给出了停用词文档stop.txt、需要统计的目标文档input.txt、以及其余文档组成的文档集合Corpus,需要计算目标文档的TF-IDF结果,返回前十个关键词
TF-IDF(Term Frequency-Inverse Document Frequency)是一种广泛应用于信息检索和文本挖掘的统计方法,用于评估一个词语在一个文档集合或语料库中的重要性。TF-IDF结合了词频(TF)和逆文档频率(IDF)两个指标,通过平衡词语在特定文档中的频率与其在整个语料库中的普遍性,来识别出对文档具有代表性和区分性的关键词
原理:通过计算词语在文档中的出现频率(TF)和该词语在整个语料库中出现的逆频率(IDF),衡量词语的重要性。
优点:简单易实现,效果在结构化文本中表现良好。
缺点:无法捕捉词语之间的关系,对语义信息考虑不足。
TF-IDF的核心思想:
词频(TF, Term Frequency) :衡量一个单词在某个文档 中出现的频率。
逆文档频率(IDF, Inverse Document Frequency) :衡量该单词在整个文档集合 中的重要性(如果一个词在许多文档中都出现,则它的区分度较低)。
TF-IDF权重 :结合TF和IDF,使得在某个文档中频繁出现但在整体文档集合中较少出现的词具有较高的权重。
词频(TF,Term Frequency) 词频(TF)是衡量某个词在文档中出现的频率,它通常有不同的计算方式,最常见的是标准词频 : $$TF(t, d) = \frac{\text{词 } t \text{ 在文档 } d \text{ 中出现的次数}}{\text{文档 } d \text{ 中的总词数}}$$ 假设我们有一个文档:
1 "The quick brown fox jumps over the lazy dog. The fox is quick."
我们想计算词 "fox"
在该文档中的 TF 值:
该文档共有 11 个词(去掉标点)。
"fox"
出现了 2 次。
因此: $$TF(\text{“fox”}) = \frac{2}{11} = 0.1818$$ 其他词的TF也可以按此计算。
变种TF计算方式:
对数归一化 :$\log(1 + \text{count}(t, d))$,有利于减小高频词对文档的影响,避免某些词对文档特征表示的过度影响
布尔TF (只考虑词是否出现,不计次数):如果单词出现,TF值设为1,否则为0。
逆文档频率(IDF,Inverse Document Frequency) 逆文档频率(IDF)用于衡量一个单词在整个文档集合(corpus)中的重要性。某些常见词(如”the”、”is”)可能在几乎所有文档中都出现,这些词的IDF值应该较低,而稀有词的IDF值应该较高。
计算公式: $$IDF(t, D) = \log\left(\frac{N}{1 + df(t)}\right)$$ 其中:
$N$ 是文档总数。
$df(t)$ 是包含单词 $t$ 的文档数量(document frequency)。
之所以加 1,是为了避免分母为 0。
示例: 假设我们有 5 篇文档:
“The cat is on the mat.”
“The dog barked at the cat.”
“The fox jumped over the dog.”
“The lazy dog slept all day.”
“A quick brown fox jumps high.” 假设我们要计算 "fox"
的 IDF:
"fox"
出现在 2 篇文档(第3篇和第5篇)。
总共有 5 篇文档。 $$IDF(\text{“fox”}) = \log\left(\frac{5}{1 + 2}\right) = \log\left(\frac{5}{3}\right) = 0.2218$$
类似地,像 "the"
这样高频词可能出现在所有5篇文档,因此它的 IDF 会更低。
TF-IDF计算 最终,我们将TF和IDF相乘,以得到TF-IDF权重: $$TFIDF(t, d, D) = TF(t, d) \times IDF(t, D)$$
示例: 假设 "fox"
在文档中的 TF 值是 0.1818,而 IDF 值是 0.2218,则: $$TFIDF(\text{“fox”}) = 0.1818 \times 0.2218 = 0.0403$$ 对于其他词,我们同样可以计算它们的TF-IDF值。
代码实现 我们可以使用 Python 和 sklearn.feature_extraction.text.TfidfVectorizer
计算TF-IDF。
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 from sklearn.feature_extraction.text import TfidfVectorizerdocuments = [ "The cat is on the mat." , "The dog barked at the cat." , "The fox jumped over the dog." , "The lazy dog slept all day." , "A quick brown fox jumps high." ] vectorizer = TfidfVectorizer() tfidf_matrix = vectorizer.fit_transform(documents) feature_names = vectorizer.get_feature_names_out() import pandas as pddf = pd.DataFrame(tfidf_matrix.toarray(), columns=feature_names) import ace_tools as toolstools.display_dataframe_to_user(name="TF-IDF 矩阵" , dataframe=df)
实验代码
read_documents
读入文档集合内容,注意,这里是一个二维列表,[[‘document1’], [‘document2’]…],后面还会通过分词处理成列表[[‘word11’, ‘word12’…],[‘word21’, ‘word22’,…],…]
compute_tf
计算词频,传入的token
实际上就是上面的[‘word11’, ‘word12’…],然后去遍历里面的内容,通过defaultdict
这个数据结构为里面的每个word统计词频,最后再做一个归一化
compute_df
实际上在遍历二维列表,第一个for
循环遍历所有的document,第二个for
循环遍历每个document里面的unique_word(这里需要用set
将原本的[‘word11’, ‘word12’…]转化成只出现一次,因为df统计的是单词在多少个文档有出现)
compute_idf
传入之前算好的df和总的文档个数,然后遍历键值对进行计算
compute_tf_idf
使用enumerate
进行枚举,先计算tf,再通过tf中存储的token找到对应的idf进行相乘
整个步骤:读取文件-分词-删去停用词-计算tf-idf
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 import osimport thulacimport mathfrom collections import defaultdictdef read_documents (folder_path ): documents = [] for filename in os.listdir(folder_path): if filename.endswith('.txt' ): filepath = os.path.join(folder_path, filename) with open (filepath, 'r' , encoding='utf-8' ) as f: text = f.read() documents.append(text) return documents def compute_tf (tokens ): tf = defaultdict(int ) for token in tokens: tf[token] += 1 total_terms = len (tokens) for token in tf: tf[token] /= total_terms return tf def compute_df (documents_tokens ): df = defaultdict(int ) for tokens in documents_tokens: unique_tokens = set (tokens) for token in unique_tokens: df[token] += 1 return df def compute_idf (df, total_docs ): idf = {} for token, freq in df.items(): idf[token] = math.log((total_docs + 1 ) / (1 + freq)) + 1 return idf def compute_tfidf (documents_tokens, idf ): tfidf = {} for i, tokens in enumerate (documents_tokens): tf = compute_tf(tokens) for token, tf_value in tf.items(): tfidf_value = tf_value * idf[token] if i in tfidf: tfidf[i][token] = tfidf_value else : tfidf[i] = {token: tfidf_value} return tfidf def remove_stopwords (filepath ): with open (filepath, 'r' , encoding='utf-8' ) as f: stopwords = f.read().split('\n' ) return stopwords thu1 = thulac.thulac(seg_only=True ) with open ("作业用到的数据\homework2_input.txt" , encoding='utf-8' ) as f: input_text = f.read() folder_path = '作业用到的数据\homework2_Corpus' documents = read_documents(folder_path) documents = [input_text] + documents stopwords_path = '作业用到的数据\homework2_stop.txt' stopwords = remove_stopwords(stopwords_path) stopwords += [',' , '。' , '、' , '“' , '”' ] documents_tokens = [] for document in documents: word_str = thu1.cut(document, text=True ) word_list = word_str.split() filtered_words = [word for word in word_list if word not in stopwords] documents_tokens.append(filtered_words) df_results = compute_df(documents_tokens) idf_results = compute_idf(df_results, len (documents)) tfidf_results = compute_tfidf(documents_tokens, idf_results) sorted_items = sorted (tfidf_results[0 ].items(), key=lambda x: x[1 ], reverse=True )[:10 ] print ("前十个TF-IDF值最高的词:" )for word, score in sorted_items: print (f"{word} : {score:.4 f} " )
需要注意的事项 defaultdict和dict的区别 看到计算df和idf的两个代码段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 def compute_df (documents_tokens ): df = defaultdict(int ) for tokens in documents_tokens: unique_tokens = set (tokens) for token in unique_tokens: df[token] += 1 return df def compute_idf (df, total_docs ): idf = {} for token, freq in df.items(): idf[token] = math.log((total_docs + 1 ) / (1 + freq)) + 1 return idf
[!question] 如果把上面的df = defaultdict(int)
改成df = {}
,会报错KeyError
。但是下面的idf
就是直接建立的字典,为什么有区别?
在 Python 中,字典(dict
)和 defaultdict
都可以用来存储键值对,但它们在处理不存在的键时的行为存在本质区别。这种区别直接导致了在某些场景下使用 defaultdict
能够避免错误,而使用普通的 dict
则可能会抛出异常。下面我将详细解释其中的原因,并通过代码示例、内部机制分析以及实际应用场景来说明这一点。
一、普通字典 (dict
) 的工作原理 在 Python 中,普通字典是一种哈希表 实现的数据结构,它通过键(key)来存储对应的值(value)。当你试图访问字典中不存在的键时,Python 默认会抛出一个 KeyError
异常。这种设计的主要目的是提醒程序员:你正在尝试访问一个未被初始化的值,这可能是程序逻辑上的错误。例如,考虑下面这个简单的代码片段:
1 2 d = {} print (d['missing' ])
执行上述代码时,Python 会抛出 KeyError: 'missing'
,因为键 'missing'
并不存在于字典 d
中。 在你的代码中,使用的是如下操作:
1 2 3 tf = {} for token in tokens: tf[token] += 1
这里的 tf[token] += 1
实际上包含了两步操作:
读取 tf[token]
的当前值;
将该值加 1 后再赋值回 tf[token]
。 对于一个普通字典,如果 token
是第一次出现,则字典中没有对应的键,也就无法读取到一个初始值,从而导致 tf[token]
的读取失败,抛出 KeyError
异常。
二、defaultdict
的设计与机制 defaultdict
是 Python 标准库 collections
模块中的一个类,它是 dict
的子类。与普通字典不同的是,defaultdict
需要在创建时传入一个工厂函数(通常是一个无参数的可调用对象),该工厂函数用于在键不存在时生成一个默认值。这样一来,当你访问一个不存在的键时,defaultdict
会自动调用这个工厂函数创建一个默认值,并将该键-值对添加到字典中。
例如,当我们使用 defaultdict(int)
时,传入的工厂函数是 int
。调用 int()
会返回 0
,因此每次访问一个不存在的键时,默认值就会被设置为 0
。这使得我们能够直接进行加法运算而无需担心键是否已经存在。下面是一个简单的例子:
1 2 3 4 from collections import defaultdictdd = defaultdict(int ) print (dd['apple' ])
在你的代码中,如果将 tf
定义为 defaultdict(int)
,即:
1 2 3 4 5 from collections import defaultdicttf = defaultdict(int ) for token in tokens: tf[token] += 1
当第一次遇到某个 token
时,tf[token]
并不存在,此时 defaultdict
会调用 int()
,返回 0
,然后进行加法操作,从而不会报错。
三、为什么会报错:dict
vs defaultdict
使用普通字典 (dict
)
当你使用普通字典时,如果对一个不存在的键执行 tf[token] += 1
,实际上发生的是:
尝试读取 tf[token]
的当前值,但由于该键不存在,Python 抛出 KeyError
;
程序无法进行后续的加法操作,因为异常中断了执行流程。 这种行为虽然在某些场景下有助于发现逻辑错误,但在需要统计或计数时,这种严格的键存在性检查反而显得繁琐和不便。
使用 defaultdict
defaultdict
的内部实现利用了特殊方法 __missing__
。当你访问 defaultdict
中一个不存在的键时,__missing__
方法会被自动调用,进而调用你在创建 defaultdict
时传入的工厂函数,从而生成一个默认值,并将这个键-值对插入到字典中。这样,tf[token] += 1
就能顺利执行,因为即使 token
第一次出现时,其默认值也已经被初始化为 0
。
具体步骤如下:
遍历 tokens
时,第一次遇到一个新的 token
,在执行 tf[token] += 1
前,字典中没有该键;
defaultdict
检查到该键不存在后,调用工厂函数 int()
,生成默认值 0
;
然后执行 0 + 1
,结果为 1
,并将键 token
和值 1
存入字典中;
以后再遇到相同的 token
时,直接在已有的值上累加,不再需要调用工厂函数。
因此,使用 defaultdict(int)
能够避免因键不存在而产生的 KeyError
异常,从而使得累加操作更加简洁和高效。
实验结果
[!question] 同样是刚刚的数据集,但是只需要对目标文档去除停用词,并完成textrank关键字提取
TextRank 是一种基于图的无监督关键词提取和文本摘要算法,灵感来源于 PageRank 算法。由 Rada Mihalcea 和 Paul Tarau 在 2004 年提出,TextRank 通过构建词语之间的关系图,利用图中节点的重要性来识别关键字或生成摘要。
构建图模型:将文本中的词语或句子作为图的节点,根据一定的规则连接节点形成边。
计算节点重要性:通过迭代算法(类似 PageRank)计算每个节点的权重,反映其在图中的重要性。
提取关键词或生成摘要:根据节点权重排序,选取权重较高的词语作为关键词,或选择相关句子作为摘要。
以中心窗口的点建边 eg. 中心为3,窗口为5,那么建立(1,3),(2,3),(3,4),(3,5)边
相对于PageRank⾥的⽆权有向图,这⾥建⽴的是⽆权⽆向图(也有的建立的是有权无向图)
TextRank 是一种 无监督 的 图排序算法 ,适用于 自然语言处理(NLP) 。它的核心思想是:
将 文本中的句子或单词 视为 图中的节点 。
通过 边(edges)连接相关的节点 ,形成一个无向图。
采用 迭代计算 来确定每个节点(单词或句子)的重要性。
选出最重要的单词作为 关键词 ,或选出最重要的句子作为 摘要 。
关键词提取的目标是从一段文本中找出最重要的 N 个词。
算法步骤
文本预处理 :
分词(Tokenization)
去掉停用词(Stopwords)
仅保留名词、动词、形容词等有意义的词
构建词图 :
将 每个词 作为 一个节点
如果两个词在 一个窗口范围内共现 ,则在它们之间添加一条边
边的 权重 由词语共现的频率决定
计算 TextRank 评分 :
使用 PageRank 公式 迭代更新每个词的权重:$$S(V_i) = (1 - d) + d \sum_{V_j \in In(V_i)} \frac{S(V_j)}{|Out(V_j)|}$$
$S(V_i)$ 是节点 $V_i$ 的重要性
$d$ 是阻尼系数(一般取 0.85)
$In(V_i)$ 是指向 $V_i$ 的所有节点
$Out(V_j)$ 是 $V_j$ 指向的节点总数
停止:
排序 & 选取关键词 :
按照得分从高到低排列
选取 前 N 个词 作为关键词
示例 假设有如下文本:
“深度学习是机器学习的一个分支,它在图像识别、自然语言处理等领域表现优秀。”
TextRank 可能会提取出 关键词 : ✅ [“深度学习”, “机器学习”, “图像识别”, “自然语言处理”]
下面是一个 Python 代码示例 ,使用 networkx
计算 TextRank 关键词:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import jiebaimport networkx as nxfrom collections import Counterfrom itertools import combinationsdef extract_keywords (text, top_k=5 , window_size=2 ): words = [w for w in jieba.cut(text) if len (w) > 1 ] graph = nx.Graph() for w1, w2 in combinations(words, 2 ): if abs (words.index(w1) - words.index(w2)) < window_size: graph.add_edge(w1, w2) scores = nx.pagerank(graph) keywords = sorted (scores, key=scores.get, reverse=True )[:top_k] return keywords text = "深度学习是机器学习的一个分支,它在图像识别、自然语言处理等领域表现优秀。" print (extract_keywords(text))
示例输出 :
1 ['深度学习', '机器学习', '图像识别', '自然语言处理']
使用jieba实现 jieba
库中有已经实现的函数
1 2 3 4 5 6 7 8 9 import jiebaimport jieba.analysewith open ('作业用到的数据\homework2_input.txt' , 'r' , encoding='utf-8' ) as f: text = f.read() res = jieba.analyse.textrank(text, topK=10 , withWeight=True , allowPOS=('ns' , 'n' )) print (res)
得到的结果如下:
可以看到,与TF-IDF比,人工智能的关键词rank同样是最高的,其他的略有不同。不同是正常的,因为TF-IDF除了目标文档外,还考虑了整个文档集合的逆文档频率IDF,IDF会对关键词的选择产生一定的影响。
手写实现
老师给了一些参考代码,让我们来看看:
build_coorrence_graph
创建邻接矩阵(graph,这里是以defaultdict套defaultdict形式出现的,区别于c和c++的数组和vector形式),然后根据窗口大小构建连边(通过+1实现(这个地方会有问题,后面进行介绍))
预处理部分同样需要读取文件,去除停用词。不过这里我们只需要保留名词,因此在模型初始化时,需要thulac(seg_only=False)
来进行处理,让模型同时能够给出词性,这样才能在后面只保留名词
TextRank需要对一开始的rank字典进行初始化,通常初始化值为 1/words
max_iter
和min_diff
是超参数,通常设置在合理值即可
通常设置窗口大小为2,这样方便跟左右两边建立连边
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 import thulacfrom matplotlib import pyplot as pltimport numpy as npfrom collections import defaultdictwith open ('作业用到的数据\homework2_input.txt' , 'r' , encoding='utf-8' ) as f: text = f.read() with open ('作业用到的数据\homework2_stop.txt' , 'r' , encoding='utf-8' ) as f: stopwords = f.read().split() stopwords = [',' , '。' , '“' , '”' , '、' , '(' , ')' ] + stopwords thu1 = thulac.thulac(seg_only=False ) words = thu1.cut(text) words = [word[0 ] for word in words if word[1 ] == 'n' ] words = [word for word in words if word not in stopwords] def build_cooccurrence_graph (words, window_size=2 ): graph = defaultdict(lambda : defaultdict(int )) for i in range (len (words)): for j in range (i + 1 , i + window_size + 1 ): if j >= len (words): break w1 = words[i] w2 = words[j] if w1 == w2: continue graph[w1][w2] += 1 graph[w2][w1] += 1 return graph graph = build_cooccurrence_graph(words) max_iter = 100 damping = 0.85 nodes = len (graph) min_diff = 1e-5 rank = defaultdict(float ) rank = {node: 1 /len (graph) for node in graph} outSum = {node: sum (graph[node].values()) for node in graph} change = [] for iteration in range (max_iter): rank_new = {} max_change = 0 for node in graph: rank_sum = 0.0 for neighbor in graph[node]: rank_sum += (graph[node][neighbor] / outSum[neighbor]) * rank[neighbor] rank_new[node] = (1 - damping) + damping * rank_sum max_change = max (max_change, abs (rank_new[node] - rank[node])) change.append(max_change) rank = rank_new if max_change < min_diff: print (f"迭代在第 {iteration+1 } 次时收敛" ) break rank_res = sorted (rank.items(), key=lambda x: x[1 ], reverse=True )[:10 ] print (rank_res)plt.plot(change) plt.xlabel("Iteration" ) plt.ylabel("Change" ) plt.title("Change of max_change in each iteration" ) plt.show()
实验结果
收敛结果: 可以看到真的发生了收敛
讨论
[!question] rank_sum里面的三种方法有什么区别?
这个地方的问题是给每个邻居节点分配的权重问题,讲义上的说法是1/(j的出边个数),这个值一定是小于1的,因此这个样子转移一定会收敛。
但是原本的代码是graph[node][neighbor]/len(graph[neighbor]
,这样直接算len,忽略了两个节点之间可能有多条连边(因为在建图的时候是共现过1次就+1),因此会发生一开始的无法收敛问题
我用的是graph[node][neighbor] / outSum[neighbor]
,即(节点i与j的共现次数)/(节点j的出边个数),这样子的值同样<1,因此也会收敛,不过跟讲义上的不太一致,因为有加权
同样的,如果要按照讲义上的说法算的话,那么应该用1 / len(graph[neighbor]
,这样也能收敛,且rank与加权一致
好吧,现在有一个新的问题,为什么原本的pagerank算法不需要加权计算呢?.
原本的pagerank计算的是有向边,拿的是链表或链式前向星实现,一般要是指向了别的页面就建个指针,不会像textrank这样反复指
链式前向星实现
原本经常写用数字表示的节点,这里用的是word指向
python里面有很好的库networkx
可以实现,如果手动的话代码如下:
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 import thulacfrom matplotlib import pyplot as pltfrom collections import defaultdictwith open ('作业用到的数据/homework2_input.txt' , 'r' , encoding='utf-8' ) as f: text = f.read() with open ('作业用到的数据/homework2_stop.txt' , 'r' , encoding='utf-8' ) as f: stopwords = f.read().split() stopwords = [',' , '。' , '“' , '”' , '、' , '(' , ')' ] + stopwords thu1 = thulac.thulac(seg_only=False ) words_tag = thu1.cut(text) words = [word for word, tag in words_tag if tag == 'n' ] words = [word for word in words if word not in stopwords] unique_words = list (set (words)) word_to_id = {w: i for i, w in enumerate (unique_words)} id_to_word = {i: w for w, i in word_to_id.items()} N = len (unique_words) window_size = 2 edge_dict = {} for i in range (len (words)): u = word_to_id[words[i]] for j in range (i+1 , min (i+window_size+1 , len (words))): v = word_to_id[words[j]] if u == v: continue a, b = (u, v) if u < v else (v, u) edge_dict[(a, b)] = edge_dict.get((a, b), 0 ) + 1 head = [-1 ] * N nxt = [] to = [] w_list = [] edge_count = 0 def add_edge (u, v, weight ): global edge_count nxt.append(head[u]) to.append(v) w_list.append(weight) head[u] = edge_count edge_count += 1 for (u, v), weight in edge_dict.items(): add_edge(u, v, weight) add_edge(v, u, weight) deg = [0 ] * N for u in range (N): i = head[u] while i != -1 : deg[u] += 1 i = nxt[i] max_iter = 100 damping = 0.85 min_diff = 1e-5 rank = [1.0 / N] * N change_list = [] for iteration in range (max_iter): rank_new = [(1 - damping)] * N for v in range (N): if deg[v] == 0 : continue contribution = damping * rank[v] / deg[v] i = head[v] while i != -1 : u = to[i] rank_new[u] += contribution i = nxt[i] max_change = max (abs (rank_new[i] - rank[i]) for i in range (N)) change_list.append(max_change) rank = rank_new if max_change < min_diff: print (f"迭代在第 {iteration+1 } 次时收敛" ) break rank_items = [(id_to_word[i], rank[i]) for i in range (N)] rank_items.sort(key=lambda x: x[1 ], reverse=True ) top10 = rank_items[:10 ] print ("Top 10 关键词及其得分:" )for word, score in top10: print (word, score) plt.plot(change_list) plt.xlabel("Iteration" ) plt.ylabel("Max Change" ) plt.title("Change of max_change in each iteration" ) plt.show()
运行结果: 结果与使用邻接矩阵结果一致