关键词提取

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 篇文档:

  1. “The cat is on the mat.”
  2. “The dog barked at the cat.”
  3. “The fox jumped over the dog.”
  4. “The lazy dog slept all day.”
  5. “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 TfidfVectorizer

# 文档集合
documents = [
"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."
]

# 初始化TF-IDF向量器
vectorizer = TfidfVectorizer()

# 计算TF-IDF矩阵
tfidf_matrix = vectorizer.fit_transform(documents)

# 获取词汇表
feature_names = vectorizer.get_feature_names_out()

# 输出TF-IDF矩阵
import pandas as pd
df = pd.DataFrame(tfidf_matrix.toarray(), columns=feature_names)
import ace_tools as tools
tools.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 os
import thulac
import math
from collections import defaultdict

# 读取文件夹内容
def 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

# 计算词频(TF)
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

# 计算文档频率(DF),这里算的是整个文档集合的频率
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

# 计算逆文档频率(IDF)
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

# 计算TF-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)
# print(type(thu1))

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 += [',', '。', '、', '“', '”'] # 手动添加一些停用词
# print(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)

# 输出第一个文档结果,按照TF-IDF值从大到小排序
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:.4f}")

需要注意的事项

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

# 计算逆文档频率(IDF)
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']) # 试图访问不存在的键 'missing'

执行上述代码时,Python 会抛出 KeyError: 'missing',因为键 'missing' 并不存在于字典 d 中。
在你的代码中,使用的是如下操作:

1
2
3
tf = {}
for token in tokens:
tf[token] += 1

这里的 tf[token] += 1 实际上包含了两步操作:

  1. 读取 tf[token] 的当前值;
  2. 将该值加 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 defaultdict

dd = defaultdict(int)
print(dd['apple']) # 由于 'apple' 不存在,会自动调用 int() 返回 0,然后输出 0

在你的代码中,如果将 tf 定义为 defaultdict(int),即:

1
2
3
4
5
from collections import defaultdict

tf = defaultdict(int)
for token in tokens:
tf[token] += 1

当第一次遇到某个 token 时,tf[token] 并不存在,此时 defaultdict 会调用 int(),返回 0,然后进行加法操作,从而不会报错。

三、为什么会报错:dict vs defaultdict
  1. 使用普通字典 (dict)

当你使用普通字典时,如果对一个不存在的键执行 tf[token] += 1,实际上发生的是:

  • 尝试读取 tf[token] 的当前值,但由于该键不存在,Python 抛出 KeyError
  • 程序无法进行后续的加法操作,因为异常中断了执行流程。
    这种行为虽然在某些场景下有助于发现逻辑错误,但在需要统计或计数时,这种严格的键存在性检查反而显得繁琐和不便。
  1. 使用 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 异常,从而使得累加操作更加简洁和高效。

实验结果

image.png|416

TextRank

[!question] 同样是刚刚的数据集,但是只需要对目标文档去除停用词,并完成textrank关键字提取

TextRank 是一种基于图的无监督关键词提取和文本摘要算法,灵感来源于 PageRank 算法。由 Rada Mihalcea 和 Paul Tarau 在 2004 年提出,TextRank 通过构建词语之间的关系图,利用图中节点的重要性来识别关键字或生成摘要。

  1. 构建图模型:将文本中的词语或句子作为图的节点,根据一定的规则连接节点形成边。
  2. 计算节点重要性:通过迭代算法(类似 PageRank)计算每个节点的权重,反映其在图中的重要性。
  3. 提取关键词或生成摘要:根据节点权重排序,选取权重较高的词语作为关键词,或选择相关句子作为摘要。

以中心窗口的点建边
eg. 中心为3,窗口为5,那么建立(1,3),(2,3),(3,4),(3,5)边

相对于PageRank⾥的⽆权有向图,这⾥建⽴的是⽆权⽆向图(也有的建立的是有权无向图)

TextRank 的基本原理

TextRank 是一种 无监督图排序算法,适用于 自然语言处理(NLP)。它的核心思想是:

  • 文本中的句子或单词 视为 图中的节点
  • 通过 边(edges)连接相关的节点,形成一个无向图。
  • 采用 迭代计算 来确定每个节点(单词或句子)的重要性。
  • 选出最重要的单词作为 关键词,或选出最重要的句子作为 摘要

TextRank 关键词提取

关键词提取的目标是从一段文本中找出最重要的 N 个词。

算法步骤

  1. 文本预处理
    • 分词(Tokenization)
    • 去掉停用词(Stopwords)
    • 仅保留名词、动词、形容词等有意义的词
  2. 构建词图
    • 每个词 作为 一个节点
    • 如果两个词在 一个窗口范围内共现,则在它们之间添加一条边
    • 边的 权重 由词语共现的频率决定
  3. 计算 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$ 指向的节点总数
  4. 停止:
    • 达到迭代次数
    • 收敛
  5. 排序 & 选取关键词
    • 按照得分从高到低排列
    • 选取 前 N 个词 作为关键词

示例

假设有如下文本:

“深度学习是机器学习的一个分支,它在图像识别、自然语言处理等领域表现优秀。”

TextRank 可能会提取出 关键词: ✅ [“深度学习”, “机器学习”, “图像识别”, “自然语言处理”]

TextRank 实现代码 by networkx

下面是一个 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 jieba
import networkx as nx
from collections import Counter
from itertools import combinations

def extract_keywords(text, top_k=5, window_size=2):
# 1. 分词
words = [w for w in jieba.cut(text) if len(w) > 1]

# 2. 构建词图
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)

# 3. 计算 TextRank 评分
scores = nx.pagerank(graph)

# 4. 排序 & 选取关键词
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 jieba
import jieba.analyse

with 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)

得到的结果如下:
image.png

可以看到,与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_itermin_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 thulac
from matplotlib import pyplot as plt
import numpy as np
from collections import defaultdict

with 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)
# print(words)

# 只保留名词
words = [word[0] for word in words if word[1] == 'n']

# 去除停用词
words = [word for word in words if word not in stopwords]
# print(len(words))

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) # 构建图
# print(graph)

max_iter = 100 # 最大迭代次数
damping = 0.85 # 阻尼系数
nodes = len(graph) # 节点数
min_diff = 1e-5 # 收敛值

rank = defaultdict(float)
# 初始化TextRank
rank = {node: 1/len(graph) for node in graph} # 初始化为 1/N
# 预计算每个节点的出边权重总和
outSum = {node: sum(graph[node].values()) for node in graph}

change = []

for iteration in range(max_iter):
rank_new = {} # 新一轮的TextRank
max_change = 0 # 最大变化量
for node in graph: # 遍历图中的所有节点
# print(f"正在处理节点 {node}")
rank_sum = 0.0
for neighbor in graph[node]: # 遍历邻居
# rank_sum += graph[node][neighbor] * (rank[neighbor] / len(graph[neighbor])) # 计算TextRank
rank_sum += (graph[node][neighbor] / outSum[neighbor]) * rank[neighbor]
# rank_sum += (1 / len(graph[neighbor])) * rank[neighbor]
rank_new[node] = (1 - damping) + damping * rank_sum # 更新TextRank
max_change = max(max_change, abs(rank_new[node] - rank[node])) # 更新最大变化量
change.append(max_change)
# print(f"迭代 {iteration+1} 次,最大变化量:{max_change:.8f}")
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)

# 绘制change值变化图
plt.plot(change)
plt.xlabel("Iteration")
plt.ylabel("Change")
# plt.yscale("log")
plt.title("Change of max_change in each iteration")
plt.show()

实验结果

image.png

收敛结果:
可以看到真的发生了收敛
image.png

讨论

[!question] rank_sum里面的三种方法有什么区别?

  1. 这个地方的问题是给每个邻居节点分配的权重问题,讲义上的说法是1/(j的出边个数),这个值一定是小于1的,因此这个样子转移一定会收敛。
  2. 但是原本的代码是graph[node][neighbor]/len(graph[neighbor],这样直接算len,忽略了两个节点之间可能有多条连边(因为在建图的时候是共现过1次就+1),因此会发生一开始的无法收敛问题
  3. 我用的是graph[node][neighbor] / outSum[neighbor],即(节点i与j的共现次数)/(节点j的出边个数),这样子的值同样<1,因此也会收敛,不过跟讲义上的不太一致,因为有加权
  4. 同样的,如果要按照讲义上的说法算的话,那么应该用1 / len(graph[neighbor],这样也能收敛,且rank与加权一致
  5. 好吧,现在有一个新的问题,为什么原本的pagerank算法不需要加权计算呢?.
  6. 原本的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 thulac
from matplotlib import pyplot as plt
from collections import defaultdict

# 读入文本与停用词
with 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 = {} # key: (min(u, v), max(u, v)) value: 权重
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 # head[u]指向u的第一条边的下标
nxt = [] # nxt[i]指向同一节点的下一条边下标
to = [] # to[i]表示第i条边的终点
w_list = [] # w_list[i]表示第i条边的权重
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

# 根据边字典构造链式前向星——对于每条无向边 (u,v) 加入两条有向边
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]

# TextRank 算法参数设置
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

# 按得分排序,输出前10个关键词
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()

运行结果:
image.png
结果与使用邻接矩阵结果一致