第一次个人编程作业

作业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)算法,特别适用于近似文档的查重。其核心思想是:

  1. 分词与词频统计:使用jieba进行中文分词,并过滤停用词(如"的、了、是、在"等高频功能词),保留有实际语义的词语。
  2. 词语哈希:对每个词语使用MD5计算64位哈希值,保证哈希的均匀分布和确定性。
  3. 加权累加:初始化长度为64的权重向量v。遍历每个词语的哈希,若第i位为1则v[i] += 词频,否则v[i] -= 词频。词频高的词语对指纹影响更大。
  4. 二值化指纹:v[i] > 0 则指纹第i位为1,否则为0。得到64位的文档指纹。
  5. 汉明距离:比较两个指纹的汉明距离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

posted @ 2026-09-12 20:44  Yanyixi  阅读(15)  评论(0)    收藏  举报