第一次个人编程作业 —— 论文查重
第一次个人编程作业 —— 论文查重
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) 常数时间。
关键步骤:
- 分词与词频:jieba 精确模式分词,过滤约 100 个常见停用词(我/的/了/
是/在……)与全部标点符号,防止高频功能词掩盖实词内容的差异; - 词语哈希:MD5 前 8 字节作 64 位哈希。不用 Python 内置 hash(),
因为它受 PYTHONHASHSEED 随机化影响,跨进程不稳定; - 加权累加:初始化 64 维权重向量,对每个词的哈希逐位按词频加权
(位 1 加词频、位 0 减词频); - 二值化:权重 > 0 的位记 1,得到 64 位指纹;
- 相似度:相似度 = 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 改进思路与效果
- 词典加载开销:jieba 首次运行加载词典约 0.4~0.7s,之后由 jieba 自身
的缓存机制(jieba.cache)降到约 0.01s。这是库的固定成本,在单次运行的
命令行场景下无法进一步消除,但远低于 5 秒的测试时限; - SimHash 指纹计算:初始实现每词固定遍历 64 个二进制位;优化为先把
权重向量初始化为 −总词频(等价于所有位按 0 位处理),再只遍历哈希值中
置 1 的位(平均 32 个)加回 2×词频——结果与优化前完全一致(用 1000 组
随机词频字典做等价性验证),计算量约减半; - 噪声字符判断: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_raises、test_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 | 含测试 |
事后总结与过程改进计划
- 预估偏差:测试阶段实际比预估多花 10 分钟——对编码与平台相关细节
(如 Windows 下子进程输出编码不一致)预估不足,这类问题难以提前估计。 - 需求分析超时:花在调研作业评测方式、参考同题作业公开博客上的时间比预想多,
但这些投入让算法选型一步到位(SimHash),避免了返工。 - 改进计划:下一轮项目在"测试"与"分析"两个阶段各加 20% 的缓冲时间;
编码前先写测试数据的构造方案,而不是编码后补。
七、小结
本次作业让我完整走了一遍"需求分析 → 设计 → 编码 → 测试 → 性能分析 →
代码质量分析"的个人软件过程。收获最大的是两点:一是认识到评测环境与
开发环境的差异(编码、路径、第三方依赖)必须提前防御;二是体会到
"先写预估 PSP、后填实际 PSP"的过程记录确实能暴露自己对工作量的判断
偏差——这次测试阶段就比预估多花了 10 分钟。

浙公网安备 33010602011771号