tfidf模型构建文章查重系统

引子

想象一个场景:现在有近九万条新闻(如下图所示),其中一部分新闻来自于新华社,还有一部分新闻来自其他媒体,如何判断其他媒体是不是抄袭了新华社的新闻呢?

其中关键在于衡量文章的特征,以及两篇文章特征的相似关系,而在文本数据中常见的特征就是关键词。因此我们可以采取以下办法

  1. 分词:按照一定规则进行提取、筛选所有文章的关键词组成特征列表
  2. 编码:每篇文章按特征列表转化成向量,如特征单词出现为1,否则为0
  3. 聚类:一般都是同类型文章间抄袭,因此按编码后的特征向量聚类
  4. 计算:计算同一簇内非新华社文章与新华社文章的相似度
  5. 筛选:筛选出相似度高的文章,即为存在抄袭的文章

提取文章中的关键词常用的方法是:tfidf(item frequency–inverse document frequency)

  • tf:词频,显然词频越大,越有可能是文章的关键词
  • idf:计算公式(sklearn中)为$log(\frac{文档数+1}{包含该单词的文档数+1})+1$,显然包含该单词的文档数越少,即该单词不太常见,则idf值越大
  • tfidf:计算公式tf*idf。显然tfidf越大,筛选出的越是不太常见但重要的词语。

实践

导入数据

1
2
3
4
5
6
7
import pandas as pd
# 导入新闻数据和停用词
with open('chinese_stopwords.txt', 'r', encoding='utf-8') as file:
stopwords = [line[:-1] for line in file.readlines()]
news = pd.read_csv('sqlResult.csv', encoding='gb18030')
# 处理缺失值
news = news.dropna(subset=['content'])

对所有新闻分词,然后筛选掉无意义的停用词,用corpus存放所有分词结果。由于数据量较大,运行一次可能需要二十分钟,可以用pickle将运行结果保存下来,下次就不用再跑一次了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import jieba
# 分词
def split_text(text):
text = text.replace(' ', '') # 去除所有空格
text = text.replace('\n', '') # 去除所有换行符
text = text.replace('\r', '') # 去除所有回车符
text2 = jieba.lcut(text.strip())
print(text2)
result = ' '.join([w for w in text2 if w not in stopwords])
return result
print(news.iloc[0].content)
print('\n', split_text(news.iloc[0].content))
# 得到语料库corpus
import pickle, os
if not os.path.exists('corpus.pkl'):
corpus = list(map(split_text, [str(item) for item in news.content]))
with open('corpus.pkl', 'wb') as file:
pickle.dump(corpus, file)
else:
# 调用上次的处理结果
with open('corpus.pkl', 'rb') as file:
corpus = pickle.load(file)

sklearn中有现成的tfidf的接口可以调用,min_df代表最小词频,这样tfidf中存放了所有新闻的特征向量。

1
2
3
4
5
6
7
8
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer, TfidfVectorizer
# 处理预料库,提取每条新闻的特征单词
countvectorizer = CountVectorizer(encoding='gb18030', min_df=0.02) # 提取所有文档中词频大于min_df的特征单词, countvectorizer.get_feature_names()
countvector = countvectorizer.fit_transform(corpus) # 返回各特征单词在每个文档中出现次数的矩阵
tfidftransformer = TfidfTransformer()
tfidf = tfidftransformer.fit_transform(countvector)
# tfidfvectorizer = TfidfVectorizer(min_df=0.3) # 上述语句可用以下两句代替,效果相同且更为简便
# tfidf = tfidfvectorizer.fit_transform(corpus)

接下来根据每条新闻的tfidf向量进行聚类。聚类分析也比较耗时,因此将运行结果保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 对全量文档按tfidf聚类
from sklearn.preprocessing import Normalizer
from sklearn.cluster import KMeans
# 保存到文件
if not os.path.exists('cluster.pkl'):
normalizer = Normalizer()
scaled = normalizer.fit_transform(tfidf.toarray()) # 归一化
kmeans = KMeans(n_clusters=25) # 这里将新闻分成25类
k_labels = kmeans.fit_predict(scaled)
with open('cluster.pkl', 'wb') as file:
pickle.dump(k_labels, file)
else:
with open('cluster.pkl', 'rb') as file:
k_labels = pickle.load(file)

接下来就可以在簇内计算非新华社新闻和新华社新闻的相似度了。不过在此之前,我们可以使用贝叶斯分类器以每条新闻的tfidf向量为特征,以是否为新华社的文章作为标签,来构造模型。将预测为”新华社”新闻,但实际非”新华社”新闻挑选出来,作为”疑似抄袭”的对象,这样可以进一步缩减之后相似度计算量,当然这一步非必须,我们后面会对比不使用分类器的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 筛选嫌疑文章
# 准备训练集
from sklearn.model_selection import train_test_split
label = list(map(lambda source: 1 if '新华社' in str(source) else 0, news.source))
X_train, X_test, y_train, y_test = train_test_split(tfidf.toarray(), label, test_size=0.3)
# 准备分类模型
from sklearn.naive_bayes import MultinomialNB
clf = MultinomialNB() # 假设先验分布为多项式分布
clf.fit(X_train, y_train)
# 筛选出预测是新华社,但实际不是(疑似抄袭的)文章
prediction = clf.predict(tfidf.toarray())
compare = pd.DataFrame({'prediction':prediction, 'label':label})
copy = compare[(compare['prediction']==1) & (compare['label']==0)]
xinhua = compare[compare['label']==1].index

这样一来,所有”疑似抄袭”的新闻都放在了copy中了。接下来就是计算每条“疑似抄袭”新闻与所有”新华社”新闻之间的相似度了,这里选定阈值为0.9,如果余弦相似度超过0.9,则认为该”疑似抄袭”新闻确实抄袭了。调用分类器进行筛选后的计算结果放入results中,不调用分类器直接从全量非新华社新闻中计算的结果放入all_results中,方便进行对比。

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
# 查找相似文档:与嫌疑文章同类,且相似度 >= 0.9
from sklearn.metrics.pairwise import cosine_similarity
def find_similar_text(cpindex, top=10):
simibase=0.9
simiindex = [] # 新华社中相似文章
similarity = [] # 相似文章的相似度
# 在新华社发布的同类文章中查找
for i in xinhua:
if k_labels[i]==k_labels[cpindex]:
similar = cosine_similarity(tfidf[cpindex], tfidf[i])[0, 0]
if similar >= simibase:
simiindex.append(i)
similarity.append(similar)
return simiindex, similarity

# 计算抄袭文章索引、所属类别、新华社相似文章索引以及相似度
if not os.path.exists('all_results_simi0.9.pkl'):
all_results = [] # 全量中筛选结果
results = [] # 嫌疑中筛选结果
for i, value in enumerate(label):
if value==0: # 所有非新华社的新闻
simiindex, similarity = find_similar_text(i)
if len(simiindex) > 0: # 列表非空,即该文章与新华社某些文章相似度大于0.9
all_results.append([i, k_labels[i], simiindex, similarity])
if i in copy.index: # 嫌疑新闻
results.append([i, k_labels[i], simiindex, similarity])
with open('all_results_simi0.9.pkl', 'wb') as file:
pickle.dump(all_results, file)
with open('results_simi0.9.pkl', 'wb') as file:
pickle.dump(results, file)
else:
with open('all_results_simi0.9.pkl', 'rb') as file:
all_results = pickle.load(file)
with open('results_simi0.9.pkl', 'wb') as file:
results = pickle.load(file)

1
2
3
4
5
6
7
8
9
10
11
# 最后输出
print('分类前:', len(all_results), '条抄袭新闻', '\t分类后:', len(results), '条抄袭新闻')
print('='*100)
for item in all_results:
print('文章编号:', item[0])
print('类型:', item[1])
print('出处:', news.iloc[item[0]].source)
print('新华社相似文章编号:', item[2])
print('相似度:', item[3])
print('分类后:有') if item in results else print('分类后:无')
print('-'*100)

实验结果

由于设置的阈值比较高,因此筛选出的”抄袭文章”较少

从中我们发现文章编号78和文章编号312相似度高达0.97,文章内容也是肉眼可的抄袭(文章内容如下),但却并没有被分类器判断为”疑似抄袭”,由此可见,此次分类器根据tfidf向量鉴别”新华社”特征并不准确。

扩展

手动计算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
import math
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
# 得到corpus的TF矩阵
countvectorizer = CountVectorizer(encoding='gb18030', min_df=0.3) # 提取所有文档中词频大于min_df的特征单词, countvectorizer.get_feature_names()
countvector = countvectorizer.fit_transform(corpus) # 返回各特征单词在每个文档中出现次数的矩阵
# 计算倒数第三条新闻中"新华社""记者"的tf和idf(因为倒数第三条新闻仅有新华社和记者两个高频词,方便后面归一化)
# tf = 该单词在文章中出现次数 / 文章总词数
# idf = log[ (1 + 文章数) / (1 + 包含该单词的文章数) ] + 1
total = len(corpus[-3].split(' '))
tf_xinhua = countvector.toarray()[-3, -3] / total # 第一个-3指倒数第3篇文章,第二个-3指"新华社"这个词
tf_jizhe = countvector.toarray()[-3, -1] / total
count_xinhua = 0
count_jizhe = 0
for item in countvector.toarray():
if item[-3]!=0:
count_xinhua = count_xinhua + 1
if item[-1]!=0:
count_jizhe = count_jizhe + 1
idf_xinhua = math.log( (1+len(corpus)) / (1+count_xinhua) ) + 1
idf_jizhe = math.log( (1+len(corpus)) / (1+count_jizhe) ) + 1
# 计算tfidf
tfidf_xinhua = tf_xinhua * idf_xinhua
tfidf_jizhe = tf_jizhe * idf_jizhe
print('L2归一之前', [tfidf_xinhua, tf_jizhe] )
# L2标准化
norm = np.sqrt(tfidf_xinhua**2 + tfidf_jizhe**2)
print('手算:', [tfidf_xinhua, tfidf_jizhe] / norm )

tfidfvectorizer = TfidfVectorizer(min_df=0.3)
tfidf = tfidfvectorizer.fit_transform(corpus)
print('调库:', tfidf.toarray()[-3, :])