第一次个人编程作业
作业GitHub链接:https://github.com/Eassy0830/3224004119
论文查重 — 基于SimHash算法的中文论文查重系统
使用 Python 3 + jieba 分词 + SimHash 局部敏感哈希实现
发布日期:2026年9月12日 | 课程:软件工程 | 语言:Python 3
一、PSP表格(预估)
在开始实现程序之前,对各个开发步骤的预估耗时如下:
| # | 开发步骤 | 预估耗时 |
|---|---|---|
| 1 | 需求分析与算法选型 | 30 min |
| 2 | 项目结构与接口设计 | 20 min |
| 3 | TextProcessor 模块实现(文件读取、分词、词频) | 40 min |
| 4 | SimHash 算法模块实现 | 45 min |
| 5 | main.py 入口与命令行参数处理 | 20 min |
| 6 | 异常处理模块设计与实现 | 15 min |
| 7 | 测试用例编写(至少10个) | 40 min |
| 8 | 测试数据文件准备 | 10 min |
| 9 | 性能分析与优化 | 30 min |
| 10 | 代码质量检查与警告消除 | 20 min |
| 11 | 博客撰写 | 40 min |
| 合计 | 310 min (约5.2h) |
二、计算模块接口的设计与实现过程
2.1 代码组织结构
项目采用模块化设计,共包含4个核心模块和1个测试包:
paper_check/
├── main.py # 入口模块:命令行参数解析与主流程
├── text_processor.py # 文本处理模块:文件读取、分词、词频统计
├── simhash.py # SimHash算法模块:指纹计算与相似度比较
├── exceptions.py # 异常定义模块:自定义异常类
├── requirements.txt # 依赖列表(jieba)
├── tests/ # 单元测试目录
│ ├── test_text_processor.py
│ ├── test_simhash.py
│ └── test_main.py
└── test_data/ # 测试数据
├── orig.txt # 原文
├── orig_add.txt # 增加版
├── orig_del.txt # 删减版
├── orig_mod.txt # 修改版
├── orig_mix.txt # 混合版
├── different.txt # 不同文本
└── empty.txt # 空文件
2.2 类与函数设计
| 模块 | 类/函数 | 职责 |
|---|---|---|
| exceptions.py | PlagiarismCheckError | 基础异常类,所有查重异常的父类 |
| FileReadError | 文件读取异常(文件不存在/无法解码) | |
| EmptyContentError | 空内容异常(文件内容为空或无有效词语) | |
| InvalidArgumentError | 参数异常(命令行参数数量不正确) | |
| text_processor.py | TextProcessor.read_file() | 读取文件,自动尝试UTF-8/GBK等多种编码 |
| TextProcessor.segment() | 使用jieba分词,过滤停用词和标点 | |
| TextProcessor.get_word_frequency() | 统计词频,返回{词语: 频率}字典 | |
| simhash.py | SimHash.init() | 接收词频字典,计算SimHash指纹 |
| SimHash._hash_word() | 使用MD5计算词语的64位哈希 | |
| SimHash._compute() | 加权累加生成最终指纹 | |
| SimHash.hamming_distance() | 计算两个指纹的汉明距离 | |
| SimHash.similarity() | 计算相似度 = 1 - 汉明距离/64 | |
| main.py | check_plagiarism() | 查重主函数:读取→分词→SimHash→输出 |
| main() | 命令行参数解析与异常捕获 |
2.3 算法流程图
┌─────────────────────────────────────┐
│ 读取原文文件 & 抄袭版文件 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ jieba中文分词 + 停用词过滤 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 统计词频字典 {word: freq} │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐ ┌──────────────────┐
│ SimHash 指纹计算 │ │ 指纹位数:64 bit │
│ 对每个词MD5哈希→加权累加→二值化 │ │ 哈希函数:MD5 │
└──────────────┬──────────────────────┘ └──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 计算汉明距离 → 相似度 = 1 - d/64 │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 写入答案文件(保留两位小数) │
└─────────────────────────────────────┘
异常处理:
├─ FileReadError (文件不存在/编码错误)
└─ EmptyContentError (文件内容为空)
2.4 算法关键与独到之处
SimHash算法是一种局部敏感哈希(LSH)算法,特别适用于近似文档的查重。其核心思想是:
- 分词与词频统计:使用jieba进行中文分词,并过滤停用词(如"的、了、是、在"等高频功能词),保留有实际语义的词语。
- 词语哈希:对每个词语使用MD5计算64位哈希值,保证哈希的均匀分布和确定性。
- 加权累加:初始化长度为64的权重向量v。遍历每个词语的哈希,若第i位为1则v[i] += 词频,否则v[i] -= 词频。词频高的词语对指纹影响更大。
- 二值化指纹:v[i] > 0 则指纹第i位为1,否则为0。得到64位的文档指纹。
- 汉明距离:比较两个指纹的汉明距离d,相似度 = 1 - d/64。
独到之处:
- 内置中文停用词表(80+词),有效过滤无语义功能词,提高查重准确度
- 支持UTF-8、GBK、GB2312等多种文件编码自动检测
- 使用MD5作为哈希函数,保证跨平台确定性(Python内置hash不保证跨运行一致)
- 对空文件、全标点等边界情况做了充分处理
三、计算模块接口部分的性能改进
3.1 性能分析数据
使用Python内置的 cProfile 模块对程序进行性能分析,结果如下:
| 函数 | 累计耗时 | 占比 | 说明 |
|---|---|---|---|
| marshal.load (jieba初始化) | 0.732s | 85.4% | jieba加载词典 |
| get_word_frequency | 0.738s | 86.1% | 含分词+词频统计(含jieba初始化) |
| segment (jieba.lcut) | 0.738s | 86.1% | 中文分词 |
| SimHash._compute | ≈0.001s | 0.1% | 指纹计算 |
| check_plagiarism (总) | 0.743s | 86.7% | 整个查重流程 |
| 总计 (首次运行) | 0.857s | 100% | 含模块导入 |
3.2 性能瓶颈可视化
jieba 词典初始化 (marshal.load) ████████████████████████████████████████ 85.4%
SimHash 指纹计算 ▏ 0.1%
文件 I/O ▏ <1%
其他(模块导入等) ██████ 13.5%
3.3 改进思路
消耗最大的函数: jieba的词典初始化
marshal.load,占85.4%的运行时间。
- jieba缓存机制:jieba首次运行时会构建前缀词典并缓存到
~/.jieba.cache,后续运行直接加载缓存,初始化时间从0.73s降至约0.01s - 按需导入:jieba的
initialize()采用延迟加载,仅在首次调用分词时触发,不影响程序启动速度 - SimHash计算优化:指纹计算本身仅约1ms,无需进一步优化。64位指纹在精度和性能之间取得良好平衡
- 文件读取优化:按编码优先级顺序尝试,UTF-8优先(最常见的编码),避免不必要的编码尝试
改进后,缓存命中时整体运行时间降至约 0.12s,远低于5秒限制。
四、计算模块部分单元测试展示
项目共编写 23个单元测试用例,覆盖三个模块的所有核心功能。总覆盖率:78%
| 模块 | 语句数 | 未覆盖 | 覆盖率 |
|---|---|---|---|
| exceptions.py | 8 | 0 | 100% |
| simhash.py | 28 | 1 | 96% |
| text_processor.py | 39 | 3 | 92% |
| main.py | 41 | 21 | 49% |
| 合计 | 116 | 25 | 78% |
注:main.py覆盖率较低是因为 if __name__ == '__main__' 块和命令行入口的异常分支难以在单元测试中覆盖。
4.1 TextProcessor 测试(8个用例)
# 测试用例1:读取UTF-8编码文件
def test_read_utf8_file(self):
"""测试读取UTF-8编码文件,应正常返回内容"""
file_path = os.path.join(self.test_data_dir, 'orig.txt')
content = TextProcessor.read_file(file_path)
self.assertTrue(len(content) > 0)
self.assertIn('今天', content)
# 测试用例2:读取不存在的文件
def test_read_nonexistent_file(self):
"""测试读取不存在的文件,应抛出FileReadError"""
with self.assertRaises(FileReadError):
TextProcessor.read_file('nonexistent_file_12345.txt')
# 测试用例5:中文分词
def test_segment_chinese_text(self):
"""测试中文分词,应正确切分词语并过滤停用词"""
text = "今天是星期天,天气晴,今天晚上我要去看电影。"
words = TextProcessor.segment(text)
self.assertTrue(len(words) > 0)
self.assertIn('今天', words)
self.assertNotIn('的', words)
# 测试用例7:停用词过滤
def test_stop_words_filtered(self):
"""测试停用词是否被正确过滤"""
text = "我今天去看了电影"
words = TextProcessor.segment(text)
self.assertNotIn('我', words) # 停用词被过滤
self.assertIn('今天', words) # 非停用词保留
self.assertIn('电影', words)
4.2 SimHash 测试(7个用例)
# 测试用例9:相同文本相似度为1.0
def test_identical_texts_similarity(self):
"""测试相同文本的SimHash相似度,应为1.0"""
freq = {'今天': 2, '星期天': 1, '电影': 1, '天气': 1}
hash1 = SimHash(freq)
hash2 = SimHash(freq)
self.assertEqual(hash1.similarity(hash2), 1.0)
# 测试用例10:完全不同文本相似度较低
def test_different_texts_similarity(self):
"""测试完全不同文本的相似度,应较低"""
freq1 = {'今天': 2, '星期天': 1, '电影': 1, '天气': 1, '晴': 1}
freq2 = {'计算机': 3, '科学': 2, '编程': 1, '算法': 1, '数据': 1}
hash1 = SimHash(freq1)
hash2 = SimHash(freq2)
similarity = hash1.similarity(hash2)
self.assertLess(similarity, 0.7)
# 测试用例14:指纹长度为64位
def test_fingerprint_length(self):
"""测试SimHash指纹长度是否为64位"""
freq = {'今天': 1, '星期天': 1}
hash_obj = SimHash(freq)
self.assertEqual(len(hash_obj.fingerprint), 64)
4.3 主函数测试(8个用例)
# 测试用例16:正常查重
def test_normal_plagiarism_check(self):
"""测试正常查重流程,相似度应在(0, 1]范围内"""
orig = os.path.join(self.test_data_dir, 'orig.txt')
plagia = os.path.join(self.test_data_dir, 'orig_mod.txt')
result = check_plagiarism(orig, plagia, self.answer_path)
self.assertGreater(result, 0.0)
self.assertLessEqual(result, 1.0)
# 测试用例17:相同文件查重
def test_identical_files(self):
"""测试原文与自身比较,相似度应为1.0"""
orig = os.path.join(self.test_data_dir, 'orig.txt')
result = check_plagiarism(orig, orig, self.answer_path)
self.assertEqual(result, 1.0)
# 测试用例19:空文件查重
def test_empty_file(self):
"""测试空文件查重,相似度应为0.0"""
orig = os.path.join(self.test_data_dir, 'orig.txt')
plagia = os.path.join(self.test_data_dir, 'empty.txt')
result = check_plagiarism(orig, plagia, self.answer_path)
self.assertEqual(result, 0.0)
# 测试用例21:输出格式验证
def test_output_format(self):
"""测试输出文件内容是否为两位小数的浮点数"""
orig = os.path.join(self.test_data_dir, 'orig.txt')
plagia = os.path.join(self.test_data_dir, 'orig_mod.txt')
check_plagiarism(orig, plagia, self.answer_path)
with open(self.answer_path, 'r', encoding='utf-8') as f:
content = f.read().strip()
value = float(content)
self.assertGreaterEqual(value, 0.0)
parts = content.split('.')
self.assertEqual(len(parts), 2)
self.assertEqual(len(parts[1]), 2) # 两位小数
4.4 测试数据构造思路
- 正常场景:原文 vs 修改版/增加版/删减版/混合版,验证不同抄袭程度下的相似度
- 边界场景:空文件、相同文件、完全不同的文件
- 异常场景:文件不存在、目录路径、参数不足
- 格式验证:输出是否为两位小数的浮点数
4.5 查重结果汇总
| 原文 vs | 相似度 | 说明 |
|---|---|---|
| 原文自身 | 1.00 | 完全相同 |
| 增加版 (orig_add) | 0.80 | 在原文基础上增加内容 |
| 删减版 (orig_del) | 0.75 | 仅保留部分原文 |
| 混合版 (orig_mix) | 0.70 | 增删改混合 |
| 修改版 (orig_mod) | 0.62 | 同义词替换较多 |
| 不同文本 (different) | 0.50 | 完全不同主题 |
| 空文件 (empty) | 0.00 | 无内容 |
五、计算模块部分异常处理说明
5.1 FileReadError — 文件读取异常
设计目标: 当文件路径不存在、路径指向目录而非文件、或文件编码无法识别时抛出。防止程序因文件问题而崩溃,提供清晰的错误信息。
# 对应单元测试
def test_read_nonexistent_file(self):
"""测试读取不存在的文件,应抛出FileReadError"""
with self.assertRaises(FileReadError):
TextProcessor.read_file('nonexistent_file_12345.txt')
def test_read_directory_path(self):
"""测试传入目录路径而非文件,应抛出FileReadError"""
with self.assertRaises(FileReadError):
TextProcessor.read_file(self.test_data_dir)
错误场景: 用户提供的文件路径不存在,或路径指向一个目录而非文件,或文件使用了不支持的编码格式。
5.2 EmptyContentError — 空内容异常
设计目标: 当文件内容为空或分词后无有效词语(如全是标点或停用词)时处理。程序不会崩溃,而是返回相似度0.00。
# 对应单元测试
def test_empty_file(self):
"""测试空文件查重,相似度应为0.0"""
orig = os.path.join(self.test_data_dir, 'orig.txt')
plagia = os.path.join(self.test_data_dir, 'empty.txt')
result = check_plagiarism(orig, plagia, self.answer_path)
self.assertEqual(result, 0.0)
错误场景: 输入的原文或抄袭版文件内容为空,或文件内容仅包含标点符号和停用词,分词后无有效词语可供比较。
5.3 InvalidArgumentError — 参数异常
设计目标: 当命令行参数数量不正确(不等于3个参数:原文、抄袭版、答案文件)时抛出,提示用户正确的用法。
# 在 main.py 中的处理
if len(sys.argv) != 4:
raise InvalidArgumentError(
"参数数量错误!\n"
"用法: python main.py [原文文件路径] [抄袭版文件路径] [答案文件路径]\n"
"示例: python main.py orig.txt orig_add.txt ans.txt"
)
错误场景: 用户运行程序时未提供足够的命令行参数,或提供了多余的参数。程序会打印用法说明并退出。
六、PSP表格(实际)
程序实现完成后,记录各模块实际花费的时间:
| # | 开发步骤 | 预估 | 实际 | 差异 |
|---|---|---|---|---|
| 1 | 需求分析与算法选型 | 30 min | 25 min | -5 min |
| 2 | 项目结构与接口设计 | 20 min | 15 min | -5 min |
| 3 | TextProcessor 模块实现 | 40 min | 30 min | -10 min |
| 4 | SimHash 算法模块实现 | 45 min | 35 min | -10 min |
| 5 | main.py 入口与参数处理 | 20 min | 15 min | -5 min |
| 6 | 异常处理模块 | 15 min | 10 min | -5 min |
| 7 | 测试用例编写 | 40 min | 45 min | +5 min |
| 8 | 测试数据文件准备 | 10 min | 10 min | 0 min |
| 9 | 性能分析与优化 | 30 min | 20 min | -10 min |
| 10 | 代码质量检查 | 20 min | 15 min | -5 min |
| 11 | 博客撰写 | 40 min | 50 min | +10 min |
| 合计 | 310 min | 270 min | -40 min |
实际耗时比预估少40分钟,主要因为SimHash算法实现比预期简单,且Python的开发效率较高。测试用例编写和博客撰写略微超时。
论文查重项目 | 基于SimHash算法 | Python 3 + jieba | 2026

浙公网安备 33010602011771号