第一次个人编程作业 —— 论文查重

第一次个人编程作业 —— 论文查重

GitHub 仓库地址:https://github.com/doinb1234/3124004121(仓库根目录下 3124004121/ 为本项目文件夹)

| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 实现论文查重命令行工具,通过 GitHub 管理源码,撰写 PSP 表格、设计文档、性能分析、单元测试与异常处理说明 |

一、PSP 表格(预估)

编码开始前填写的预估耗时:

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划
· Estimate · 估计这个任务需要多少时间 25
Development 开发
· Analysis · 需求分析 (包括学习新技术) 45
· Design Spec · 生成设计文档 30
· Design Review · 设计复审 15
· Coding Standard · 代码规范 15
· Design · 具体设计 45
· Coding · 具体编码 120
· Code Review · 代码复审 30
· Test · 测试(自我测试,修改代码,提交修改) 60
Reporting 报告
· Test Repor · 测试报告 30
· Size Measurement · 计算工作量 10
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 20
· 合计 445

二、计算模块接口的设计与实现过程

2.1 代码组织结构

项目采用模块化设计,按单一职责拆分为 5 个模块 + 测试包:

3124004121/
├── main.py              # 入口:命令行参数解析与查重流程编排
├── exceptions.py        # 自定义异常层级
├── file_io.py           # 文件读写(UTF-8/GBK 编码自动识别)
├── text_processor.py    # 分词、停用词过滤、词频统计
├── simhash.py           # SimHash 指纹、汉明距离、相似度
├── requirements.txt     # jieba==0.42.1
├── docs/                # 设计文档、PSP、性能分析与覆盖率图
├── tests/               # 4 个测试文件、44 个单元测试用例
└── test_data/           # 测试数据

2.2 类与函数设计

模块 类/函数 职责
exceptions.py PlagiarismCheckError 所有查重异常基类
InvalidArgumentError 参数个数错误
FileReadError 文件不存在/是目录/编码无法识别
EmptyContentError 分词后无有效词语
file_io.py read_text(path) UTF-8(含BOM)→GB18030 顺序尝试解码
write_text(path, content) UTF-8 写答案文件,自动建目录
text_processor.py tokenize(text) jieba 分词 + 过滤标点/空白/停用词
word_frequency(words) 词频统计,空时抛 EmptyContentError
simhash.py compute_simhash(freq) 词频字典 → 64 位指纹
hamming_distance(fp1, fp2) 汉明距离
similarity(fp1, fp2) 1 − 距离/64
main.py check_plagiarism(o, p, a) 主流程编排,返回重复率
main(argv) 参数校验

2.3 模块调用关系与流程图

3. 模块间调用关系

main(argv)                      异常类(exceptions.py)
  │  参数个数 != 3 → 抛 InvalidArgumentError
  └─ check_plagiarism(orig, plag, answer)
       ├─ read_text(orig)        file_io.py(FileReadError)
       ├─ read_text(plag)
       ├─ tokenize(text) ×2      text_processor.py(jieba / 二元组兜底)
       ├─ word_frequency ×2      → 空则抛 EmptyContentError → 记 0.00
       ├─ compute_simhash ×2     simhash.py(_hash64 → 加权 → 二值化)
       ├─ similarity(fp1, fp2)   (hamming_distance → 1 − d/64)
       └─ write_text(answer, f"{rate:.2f}")   file_io.py

4. 算法流程图

        ┌──────────────────────────────┐
        │ 读取原文 & 抄袭版文件(命令行 │
        │ 参数给定的两个绝对路径)      │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ jieba 中文分词 + 标点/空白/  │
        │ 停用词过滤 → 词频字典        │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐    ┌────────────────┐
        │ SimHash 指纹计算(64 位)    │    │ 哈希函数: MD5   │
        │ 每词哈希 → 按词频加权累加    │    │ 指纹位数: 64 bit│
        │ → 符号位二值化               │    │ 权重: 词频      │
        └──────────────┬───────────────┘    └────────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ 汉明距离 d → 相似度 = 1−d/64 │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ 答案文件写入浮点型,保留两位 │
        │ 小数(如 0.80)              │
        └──────────────────────────────┘

2.4 算法关键与独到之处

算法选择:SimHash + 汉明距离。 论文查重比较的是"近似"文本:抄袭版经过
增删改后与原文不再相等,传统哈希无法度量"有多像";SimHash 作为局部敏感哈希,
内容相近的文本指纹的汉明距离也相近,且 64 位指纹比较是 O(1) 常数时间。

关键步骤:

  1. 分词与词频:jieba 精确模式分词,过滤约 100 个常见停用词(我/的/了/
    是/在……)与全部标点符号,防止高频功能词掩盖实词内容的差异;
  2. 词语哈希:MD5 前 8 字节作 64 位哈希。不用 Python 内置 hash(),
    因为它受 PYTHONHASHSEED 随机化影响,跨进程不稳定;
  3. 加权累加:初始化 64 维权重向量,对每个词的哈希逐位按词频加权
    (位 1 加词频、位 0 减词频);
  4. 二值化:权重 > 0 的位记 1,得到 64 位指纹;
  5. 相似度:相似度 = 1 − 汉明距离/64。相同文本 d=0 → 1.00;
    无关文本指纹约一半位不同 → 约 0.50。

独到之处:

  • 无 jieba 兜底:评测环境若未安装 jieba,自动退化为字符二元组切分,
    程序仍正常运行;requirements.txt 锁定 jieba==0.42.1 保证确定性;
  • 编码自适应:UTF-8(含 BOM)→ GB18030 顺序尝试,兼容记事本保存的
    国标编码文件;
  • 指纹计算优化:只遍历哈希值中置 1 的位(平均 32 个,而非固定 64 个),
    "先整体减词频、再对置位加回 2×词频"免去分支判断(详见第三节);
  • 噪声判断缓存:lru_cache 缓存单字符 Unicode 类别判定结果;
  • 空内容语义:空文件/纯标点/纯停用词统一按 0.00 处理并正常写出答案文件。

三、计算模块接口部分的性能改进

3.1 性能分析(改进前)

使用 Python 内置 cProfile 对典型规模(orig.txt vs orig_add.txt)运行做剖析,
消耗最大的函数是 jieba 词典加载 marshal.load,占总耗时约 99%
而程序自身的分词、指纹计算、文件读写合计不到 2 毫秒:

性能分析图

函数 累计耗时 占比 说明
marshal.load (jieba 词典加载) 0.501s 98.8% 一次性固定开销
check_plagiarism (总) 0.507s 100% 整个查重流程
write_text / read_text ≈0.002s <1% 文件读写
SimHash 指纹计算 ≈0.001s <1% 核心算法本身

3.2 改进思路与效果

  1. 词典加载开销:jieba 首次运行加载词典约 0.4~0.7s,之后由 jieba 自身
    的缓存机制(jieba.cache)降到约 0.01s。这是库的固定成本,在单次运行的
    命令行场景下无法进一步消除,但远低于 5 秒的测试时限;
  2. SimHash 指纹计算:初始实现每词固定遍历 64 个二进制位;优化为先把
    权重向量初始化为 −总词频(等价于所有位按 0 位处理),再只遍历哈希值中
    置 1 的位(平均 32 个)加回 2×词频——结果与优化前完全一致(用 1000 组
    随机词频字典做等价性验证),计算量约减半;
  3. 噪声字符判断:unicodedata.category 逐字符调用较慢,加 lru_cache
    缓存后函数调用次数从 3.78M 降到 3.14M。

改进效果(694KB 长文本):总耗时 1.471s → 1.316s。

性能优化对比图

四、计算模块部分单元测试展示

4.1 测试框架与运行方式

使用 Python 标准库 unittest + coverage.py 分支覆盖率:

python -m unittest discover -s tests -t .          # 运行全部 44 个用例
python -m coverage run --branch -m unittest discover -s tests -t .
python -m coverage report -m                       # 查看覆盖率

4.2 测试用例设计思路(白盒为主)

按模块划分,覆盖正常路径、边界路径与异常路径:

  • simhash 模块(白盒):哈希稳定性(同词同哈希、不同词不同哈希)、
    指纹 64 位范围、插入顺序无关性、汉明距离的对称性/上界、相似度区间;
  • text_processor 模块(白盒):实词保留、停用词与标点过滤、纯标点/空
    串/纯空白为空、兜底二元组切分(mock 掉 jieba 验证兜底路径)、词频计数;
  • file_io 模块:UTF-8/GBK/BOM 读取、不存在文件/目录/不可解码异常、
    写入回读、自动创建父目录;
  • main 模块(集成):相同文件=1.00、空文件/纯标点=0.00、答案两位小数
    格式、同义替换在 [0.5, 0.95]、相似度排序合理性、参数个数异常、
    命令行端到端(退出码与答案内容)、答案路径为目录时非零退出。

4.3 部分测试代码

# tests/test_main.py —— 作业示例的同义词替换场景
def test_synonym_replacement_in_reasonable_range(self):
    """作业示例的同义词替换(星期天→周天)应在 [0.5, 0.95]。"""
    rate = check_plagiarism(_path("orig.txt"), _path("orig_mod.txt"),
                            self.answer)
    self.assertGreaterEqual(rate, 0.5)
    self.assertLessEqual(rate, 0.95)

# tests/test_main.py —— 输出格式校验
def test_answer_has_two_decimal_places(self):
    """答案文件必须是保留两位小数的浮点型(如 0.80)。"""
    check_plagiarism(_path("orig.txt"), _path("orig_add.txt"), self.answer)
    with open(self.answer, "r", encoding="utf-8") as file_obj:
        content = file_obj.read()
    self.assertRegex(content, r"^\d+\.\d{2}$")

# tests/test_simhash.py —— 白盒:哈希稳定性
def test_hash_is_deterministic(self):
    """同一个词多次哈希,结果必须一致(跨调用稳定)。"""
    self.assertEqual(_hash64("今天"), _hash64("今天"))

# tests/test_text_processor.py —— 白盒:无 jieba 兜底路径
def test_tokenize_uses_fallback_without_jieba(self):
    """模拟 jieba 未安装:tokenize 应走兜底路径且正常运行。"""
    with mock.patch("text_processor.jieba", None):
        words = tokenize("天气很好")
    self.assertEqual(words, ["天气", "气很", "很好"])

4.4 测试数据构造思路

test_data/ 下构造:原文(作业示例句)、增加版、删减版、修改版(同义词替换)、
混合版、完全无关文本、空文件、纯标点文件、GBK 编码文件、带 BOM 文件、
长文本(性能测试)。查重结果:

对比对象 相似度 说明
原文自身 1.00 完全相同
增加版 0.83 原文基础上增加内容
删减版 0.92 删除少量词语
修改版 0.72 同义词替换(星期天→周天等)
混合版 0.66 增删改混合
不同主题 0.55 完全无关文本
空文件/纯标点 0.00 无有效内容

(本表数值随测试数据的改写程度不同而不同,同题公开博客中相同算法的
参考结果:增加版 0.80、删减版 0.75、修改版 0.62、无关文本 0.50,
本文档项目数值与之处于同一量级。)

4.5 覆盖率

44 个用例全部通过,总分支覆盖率 95%。main.py 的 77% 是因为
if __name__ == "__main__" 入口块在子进程中执行,coverage 无法追踪,
其余模块均在 94% 以上。

覆盖率图

五、计算模块部分异常处理说明

5.1 InvalidArgumentError —— 参数异常

  • 设计目标:命令行参数个数不等于 3 时抛出,避免程序带错误状态继续执行,
    并把正确用法打印到 stderr、以状态码 1 退出;
  • 错误场景:用户运行程序时参数缺失或多余,如
    python main.py orig.txt
  • 对应单元测试test_wrong_arg_count_raises(2 个参数与 4 个参数两种
    情况均断言抛出 InvalidArgumentError)。

5.2 FileReadError —— 文件读取异常

  • 设计目标:输入文件不存在、路径指向目录、文件编码无法识别时抛出,
    保证程序不因文件问题崩溃,并输出可读的错误信息;
  • 错误场景:用户手误写错路径、把目录当文件传入、或文件是二进制格式;
  • 对应单元测试test_read_nonexistent_file_raises
    test_read_directory_raisestest_undecodable_file_raises
    (内容为 \x80\x80\x80 的文件两种编码都无法解码)。

5.3 EmptyContentError —— 空内容异常

  • 设计目标:文件内容为空或分词后无有效词语(全标点/全停用词)时抛出,
    由查重主流程捕获并按重复率 0.00 写出答案文件——这类输入不是"错误",
    而是合法的边界情况,因此程序不崩溃、正常产出 0.00;
  • 错误场景:空文件、只有标点的文件、只有"的的的"这类停用词的文件;
  • 对应单元测试test_empty_words_raises_empty_content_error(白盒,
    空词列表抛异常)、test_empty_file_rate_zero(空文件 → 0.00)、
    test_punctuation_only_rate_zero(纯标点 → 0.00)。

六、PSP 表格(实际)

PSP2.1 个人软件过程表 —— 论文查重项目

学号:3124004121
作业:第一次个人编程作业(论文查重)
说明:预估耗时为编码开始前填写(2026-09-13);实际耗时在项目完成后回填。

PSP2.1 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
· Estimate · 估计这个任务需要多少时间 25 20
Development 开发
· Analysis · 需求分析 (包括学习新技术) 45 60
· Design Spec · 生成设计文档 30 25
· Design Review · 设计复审 15 10
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 15 10
· Design · 具体设计 45 40
· Coding · 具体编码 120 90
· Code Review · 代码复审 30 25
· Test · 测试(自我测试,修改代码,提交修改) 60 70
Reporting 报告
· Test Repor · 测试报告 30 25
· Size Measurement · 计算工作量 10 10
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 20 25
· 合计 445 410

工作量统计(Size Measurement)

模块 行数 说明
exceptions.py 29 异常层级(4 个异常类)
file_io.py 57 文件读写(编码识别)
text_processor.py 105 分词 / 停用词 / 词频
simhash.py 71 SimHash 指纹 / 距离 / 相似度
main.py 88 入口与流程编排
tests/(4 个文件) 430 44 个单元测试用例
合计 782 含测试

事后总结与过程改进计划

  1. 预估偏差:测试阶段实际比预估多花 10 分钟——对编码与平台相关细节
    (如 Windows 下子进程输出编码不一致)预估不足,这类问题难以提前估计。
  2. 需求分析超时:花在调研作业评测方式、参考同题作业公开博客上的时间比预想多,
    但这些投入让算法选型一步到位(SimHash),避免了返工。
  3. 改进计划:下一轮项目在"测试"与"分析"两个阶段各加 20% 的缓冲时间;
    编码前先写测试数据的构造方案,而不是编码后补。

七、小结

本次作业让我完整走了一遍"需求分析 → 设计 → 编码 → 测试 → 性能分析 →
代码质量分析"的个人软件过程。收获最大的是两点:一是认识到评测环境与
开发环境的差异(编码、路径、第三方依赖)必须提前防御;二是体会到
"先写预估 PSP、后填实际 PSP"的过程记录确实能暴露自己对工作量的判断
偏差——这次测试阶段就比预估多花了 10 分钟。

posted @ 2026-09-13 15:32  gengdoinb  阅读(6)  评论(0)    收藏  举报