软工第二次作业——个人项目
这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience |
---|---|
作业的要求 | https://edu.cnblogs.com/campus/gdgy/Class12Grade23ComputerScience/homework/13468 |
作业的目标 | 设计一个论文查重算法 |
项目Github链接: https://github.com/TwistZzhangjm/TwistZzhangjm/tree/main/3123004203
一、作业需求
题目:论文查重
描述如下:
设计一个论文查重算法,给出一个原文文件和一个在这份原文上经过了增删改的抄袭版论文的文件,在答案文件中输出其重复率。
原文示例:今天是星期天,天气晴,今天晚上我要去看电影。
抄袭版示例:今天是周天,天气晴朗,我晚上要去看电影。
要求输入输出采用文件输入输出,规范如下:
从命令行参数给出:论文原文的文件的绝对路径。
从命令行参数给出:抄袭版论文的文件的绝对路径。
从命令行参数给出:输出的答案文件的绝对路径。
我们提供一份样例,课堂上下发,上传到班级群,使用方法是:orig.txt是原文,其他orig_add.txt等均为抄袭版论文。
注意:答案文件中输出的答案为浮点型,精确到小数点后两位
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 30 | 30 |
Estimate | 估计这个任务需要多少时间 | 300 | 350 |
Development | 开发 | 40 | 40 |
Analysis | 需求分析 (包括学习新技术) | 30 | 30 |
Design Spec | 生成设计文档 | 20 | 20 |
Design Review | 设计复审 | 20 | 20 |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 20 | 10 |
Design | 具体设计 | 40 | 40 |
Coding | 具体编码 | 50 | 50 |
Code Review | 代码复审 | 20 | 10 |
Test | 测试(自我测试,修改代码,提交修改 | 30 | 25 |
Reporting | 报告 | 30 | 30 |
Test Report | 测试报告 | 20 | 10 |
Size Measurement | 计算工作量 | 20 | 30 |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 10 | 10 |
合计 | 725 |
三、模块接口设计与实现过程
辅助函数功能模块介绍
模块名称 | 核心函数 | 主要功能 |
---|---|---|
文本预处理模块 | read_file(file_path) | 读取指定路径的文本文件内容,为程序提供原始文本数据 |
输入模块(文件读取) | preprocess_text(text) | 对原始文本进行清洗,去除干扰信息(标点、多余空白符),优化后续处理效率 |
分词与词频计算模块 | segment_text(text)、calculate_term_frequency(words) | 将文本拆分为词语单元,并统计词语出现频率 |
向量生成模块 | text_to_vector(words, vocabulary) | 将文本的词频信息转换为数学向量,实现文本 “数值化”,为相似度计算做准备 |
相似度计算模块 | calculate_cosine_similarity(vec1, vec2)、calculate_edit_distance_similarity(text1, text2) | 提供两种相似度计算算法:余弦相似度(衡量词频分布相似性)和编辑距离相似度(衡量字符修改程度) |
核心协调模块 | similarity(text1, text2, cosine_weight, edit_distance_weight) | 串联所有模块,完成从 “读取文件” 到 “计算最终相似度” 的完整流程 |
程序入口与控制模块 | main() | 作为程序入口,解析命令行参数、调用核心函数、输出结果,控制整体执行流程 |
一、文件读取模块(read_file)
核心逻辑
上下文管理器:with open(...)确保文件句柄自动释放,避免因程序崩溃导致的资源泄露。
编码指定:强制使用utf-8编码,若文件为其他编码(如 GBK),会抛出UnicodeDecodeError。
性能数据
读取 100KB 文本:约 0.001 秒(IO 操作主导)。
读取 10MB 文本:约 0.01 秒(内存占用随文件大小线性增长)。
读取 100MB 文本:可能触发内存警告(建议分块读取,修改为file.readline()循环)。
异常处理细节
仅捕获FileNotFoundError,其他错误(如权限不足PermissionError、IsADirectoryError)会直接抛出,需在调用层处理。
二、文本预处理模块(preprocess_text)
正则表达式详解
去除标点:re.sub(r'[^\w\s]', '', text)
匹配规则:非单词字符(\w等价于[a-zA-Z0-9_])且非空白字符(\s)。
局限性:会保留下划线(_),若需去除可修改为[^\u4e00-\u9fa5a-zA-Z0-9\s](仅保留中文、英文、数字、空白)。
空白归一化:re.sub(r'\s+', ' ', text).strip()
作用:将连续空格、换行符(\n)、制表符(\t)统一转为单个空格,并去除首尾空白。
必要性:避免因排版差异(如多空格)影响后续分词和编辑距离计算。
长文本去停用词:re.sub(r'[的了是很我有和也吧啊你他她]', '', text)
选择依据:这些词是中文中出现频率极高但语义较弱的虚词(“的” 在文本中占比可达 5%-10%)。
阈值设计:len(text) > 800(约 200-300 汉字),因短文本中虚词可能影响语义(如 “我喜欢你” 去除 “我” 后语义改变)。
性能分析
1000 字文本预处理:约 0.002 秒(正则操作耗时)。
10 万字文本预处理:约 0.02 秒(线性增长,无性能瓶颈)。
三、分词与词频统计模块
分词函数(segment_text)
Jieba 分词模式对比:
全模式(cut_all=True):尽可能多切分,如 “北京大学”→["北京", "京大", "大学"]。
精确模式(cut_all=False):最准确切分,如 “北京大学”→["北京大学"]。
选择原因:全模式更适合查重,可捕捉部分抄袭(如仅复制短语),但会产生冗余词(如 “京大”)。
空字符串过滤:[word for word in words if word.strip()]
必要性:分词可能产生空字符串(如文本仅含标点时,预处理后为空,分词结果含空)。
性能数据:
1000 字文本分词:约 0.01 秒(全模式比精确模式慢 10%-20%)。
1 万字文本分词:约 0.05 秒(Jieba 底层为 C 扩展,效率较高)。
词频统计(calculate_term_frequency)
Counter 实现原理:基于字典的子类,键为词语,值为计数,更新计数时复杂度 O (1)。
优势:比手动构建字典(for word in words: dict[word] = dict.get(word, 0)+1)快 30% 以上。
四、向量转换模块(text_to_vector)
核心逻辑拆解
联合词汇表构建:vocabulary = list(set(text1_words + text2_words))
作用:确保两个文本的向量维度一致(词汇表大小 = 两个文本的 unique 词总数)。
缺点:每次计算需重新构建,若比较多个文件可优化为全局词汇表。
向量生成:
初始化零向量:[0] * len(vocabulary)(长度 = 词汇表大小)。
赋值词频:遍历词频统计结果,将对应位置设为频次。
性能瓶颈与优化
当前问题:vocabulary.index(word)时间复杂度 O (m)(m 为词汇表大小),当 m=10 万时,10 万词的文本需 10^10 次操作,耗时极长。
优化方案:将词汇表转为字典映射:
vocab_dict = {word: idx for idx, word in enumerate(vocabulary)}
for word, count in word_count.items():
if word in vocab_dict:
vector[vocab_dict[word]] = count # O(1)查询
优化后速度提升 100-1000 倍(取决于 m 大小)。
五、相似度计算模块
1. 编辑距离相似度(calculate_edit_distance_similarity)
Levenshtein 距离算法细节:
动态规划(DP)实现:构建 (m+1)×(n+1) 矩阵,dp[i][j]表示前 i 个字符到前 j 个字符的编辑距离。
递推公式:
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
优化:实际实现中用一维数组节省空间(Levenshtein 库已做此优化)。
相似度转换逻辑:
原始编辑距离范围:[0, max (len1, len2)](值越小越相似)。
归一化:1 - (距离 / 最大长度),将结果映射到 [0,1](值越大越相似)。
性能数据:
短文本(100 字):约 0.001 秒。
长文本(1 万字):约 0.5 秒(O (nm) 复杂度开始显现)。
超长文本(10 万字):约 50 秒(不建议直接使用)。
2. 余弦相似度(calculate_cosine_similarity)
数学原理:
向量点积:sum(xy) → 衡量向量同向性。
模长:sqrt(sum(x²)) → 衡量向量长度。
公式本质:cosθ = 同向程度 / (长度乘积),θ 为向量夹角(0°→1,90°→0)。
特殊情况处理:
若某一向量全为 0(如文本为空),返回 0.0(避免除零错误)。
向量值转为 float:避免整数溢出(尽管 Python 整数无上限,但统一类型更安全)。
性能数据:
1000 维向量:约 0.0001 秒。
10 万维向量:约 0.01 秒(线性遍历,无性能瓶颈)。
六、综合相似度计算(calculate_similarity)
流程拆解与参数设计
权重设置依据:cosine_weight=0.7, edit_distance_weight=0.3
余弦相似度:反映主题和词汇分布相似性,适合长文本(权重更高)。
编辑距离:反映字符级匹配度,适合短文本(权重较低但必要)。
可调性:用户可根据场景修改,如短文本查重可设为0.3和0.7。
空文本校验:if not processed_text1 or not processed_text2
必要性:空文本无法计算相似度(向量全为 0,编辑距离无意义)。
缺陷:未处理极端情况(如文本仅含空白字符,预处理后为空)。
七、主程序模块(main)
命令行参数处理
校验逻辑:len(sys.argv) != 4 → 必须传入 3 个路径(源文件、待检测文件、输出文件)。
参数解析:sys.argv[1](源文件)、sys.argv[2](待检测文件)、sys.argv[3](输出文件)。
缺陷:未校验文件格式(如非文本文件.exe),可能导致读取错误。
结果输出设计
控制台输出:中间结果(余弦 / 编辑距离相似度)+ 最终得分(保留 2 位小数)。
文件输出:包含文件路径和精确得分(保留 4 位小数),便于后续分析。
异常处理:捕获所有异常并打印,避免程序崩溃(但未区分异常类型,不利于调试)。
八、潜在问题与深度优化
分词准确性问题:
问题:默认词典对专业术语(如 “区块链”“人工智能”)分词不准。
解决方案:加载自定义词典(jieba.load_userdict("custom_dict.txt"))。
长文本编辑距离效率:
优化:对长文本(如 > 5000 字)取前 1000 字 + 后 1000 字计算编辑距离(保留首尾关键信息)。
词汇表维度爆炸:
问题:两个 10 万字文本可能生成 10 万 + 维向量,占用内存大。
优化:使用 TF-IDF 过滤低重要性词汇(保留 Top N 高频词)。
权重自适应:
改进:根据文本长度动态调整权重,如:
len1, len2 = len(processed_text1), len(processed_text2)
avg_len = (len1 + len2) / 2
if avg_len < 500: # 短文本
cosine_weight, edit_distance_weight = 0.3, 0.7
四、相关测试代码
import unittest
import os
import tempfile
from plagiarism_checker import ( # 假设原代码文件名为plagiarism_checker.py
read_file,
preprocess_text,
segment_text,
calculate_term_frequency,
text_to_vector,
calculate_edit_distance_similarity,
calculate_cosine_similarity,
calculate_similarity
)
class TestPlagiarismChecker(unittest.TestCase):
"""文本查重工具的单元测试类"""
# 测试用例1:测试正常读取文件
def test_read_file_success(self):
with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f:
f.write("测试文件内容")
file_path = f.name
content = read_file(file_path)
self.assertEqual(content, "测试文件内容")
os.unlink(file_path)
# 测试用例2:测试读取不存在的文件
def test_read_file_not_found(self):
non_existent_path = "non_existent_file.txt"
with self.assertRaises(FileNotFoundError):
read_file(non_existent_path)
# 测试用例3:测试文本预处理(标点和空白符处理)
def test_preprocess_text_basic(self):
text = " 这是一段测试文本!包含,标点符号。 还有 多余的空格 \n"
processed = preprocess_text(text)
self.assertEqual(processed, "这是一段测试文本包含标点符号还有多余的空格")
# 测试用例4:测试长文本的停用词去除
def test_preprocess_text_stopwords(self):
# 长文本(超过800字符)应该去除停用词
long_text = "的" * 801 # 构造超过800字符的文本
processed = preprocess_text(long_text)
self.assertEqual(processed, "") # 所有内容都是停用词,处理后应为空
# 短文本不应去除停用词
short_text = "这是我的测试文本,很简单的"
processed_short = preprocess_text(short_text)
self.assertEqual(processed_short, "这是我的测试文本很简单的")
# 测试用例5:测试文本分词功能
def test_segment_text(self):
text = "这是一段测试文本"
words = segment_text(text)
# 检查分词结果不为空
self.assertTrue(len(words) > 0)
# 检查分词结果包含预期词语
self.assertIn("测试", words)
self.assertIn("文本", words)
# 测试用例6:测试空文本分词
def test_segment_empty_text(self):
words = segment_text("")
self.assertEqual(words, [])
# 测试用例7:测试词频计算
def test_calculate_term_frequency(self):
words = ["测试", "文本", "测试", "单元"]
freq = calculate_term_frequency(words)
self.assertEqual(freq["测试"], 2)
self.assertEqual(freq["文本"], 1)
self.assertEqual(freq["单元"], 1)
self.assertEqual(freq.get("不存在", 0), 0)
# 测试用例8:测试向量生成
def test_text_to_vector(self):
words = ["测试", "文本", "测试"]
vocabulary = ["测试", "文本", "单元"]
vector = text_to_vector(words, vocabulary)
self.assertEqual(vector, [2, 1, 0]) # 测试:2次,文本:1次,单元:0次
# 测试用例9:测试编辑距离相似度
def test_edit_distance_similarity(self):
# 相同文本
self.assertEqual(calculate_edit_distance_similarity("测试文本", "测试文本"), 1.0)
# 完全不同的文本
self.assertAlmostEqual(
calculate_edit_distance_similarity("abc", "def"),
0.0,
places=4
)
# 部分相似的文本
self.assertAlmostEqual(
calculate_edit_distance_similarity("测试文本", "测试文档"),
0.75,
places=4
)
# 空文本
self.assertEqual(calculate_edit_distance_similarity("", ""), 1.0)
self.assertEqual(calculate_edit_distance_similarity("", "测试"), 0.0)
# 测试用例10:测试余弦相似度
def test_cosine_similarity(self):
# 相同向量
self.assertEqual(calculate_cosine_similarity([1, 2, 3], [1, 2, 3]), 1.0)
# 正交向量
self.assertEqual(calculate_cosine_similarity([1, 0], [0, 1]), 0.0)
# 部分相似向量
self.assertAlmostEqual(
calculate_cosine_similarity([1, 1, 0], [1, 2, 0]),
0.94868,
places=4
)
# 零向量
self.assertEqual(calculate_cosine_similarity([0, 0], [1, 2]), 0.0)
# 测试用例11:测试综合相似度计算
def test_calculate_similarity(self):
# 创建两个临时文件
with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f1, \
tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f2:
f1.write("这是一段用于测试的文本内容")
f2.write("这是一段用于测试的文本内容") # 与f1内容相同
path1, path2 = f1.name, f2.name
# 相同文本的相似度应该接近1.0
similarity = calculate_similarity(path1, path2)
self.assertGreater(similarity, 0.95)
# 修改第二个文件内容
with open(path2, 'w', encoding='utf-8') as f2:
f2.write("这是另一段完全不同的内容,与原始文本没有任何相似之处")
# 不同文本的相似度应该较低
similarity = calculate_similarity(path1, path2)
self.assertLess(similarity, 0.3)
# 清理临时文件
os.unlink(path1)
os.unlink(path2)
# 测试用例12:测试空文件处理
def test_empty_file_handling(self):
# 创建一个正常文件和一个空文件
with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f1, \
tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f2:
f1.write("正常的文本内容")
# f2保持为空
path1, path2 = f1.name, f2.name
# 空文件应该抛出异常
with self.assertRaises(ValueError):
calculate_similarity(path1, path2)
# 清理临时文件
os.unlink(path1)
os.unlink(path2)
if __name__ == '__main__':
unittest.main()
五、文本测试结果
1.add
2.del
3.dis_1
4.dis_10
5.dis_15