论文查重程序(第一次正式软工作业)
第一次个人编程作业:论文查重
一、开发前的 PSP 预估
这次作业需要完成一个论文查重程序。开始写代码前,我先按 PSP 表格估计了一下各阶段需要的时间。虽然当时还不知道算法最后会改几次,但先做预估至少能让我知道任务不只是“把 main.py 写出来”,设计、测试和复盘也要留下时间。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | — |
| · Estimate | · 估计任务需要的时间 | 20 |
| Development | 开发 | — |
| · Analysis | · 需求分析,包括学习相关技术 | 70 |
| · Design Spec | · 生成设计文档 | 50 |
| · Design Review | · 设计复审 | 30 |
| · Coding Standard | · 制定代码规范 | 25 |
| · Design | · 具体设计 | 80 |
| · Coding | · 具体编码 | 210 |
| · Code Review | · 代码复审 | 50 |
| · Test | · 测试、修改代码和覆盖率检查 | 180 |
| Reporting | 报告 | — |
| · Test Report | · 测试报告 | 70 |
| · Size Measurement | · 计算工作量 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结与改进计划 | 50 |
| 合计 | 855 |
二、需求理解
程序需要接收原文、抄袭版论文和答案文件三个路径,计算两篇文本的重复率,再把保留两位小数的结果写入答案文件。Python 入口固定为 main.py,运行方式如下:
python main.py [原文文件] [抄袭版论文文件] [答案文件]
我把任务拆成了三层:文件读写负责处理路径和文本,查重模块负责文本规范化与相似度计算,main.py 只负责接收参数并把这些步骤串起来。这样算法需要调整时,不用反复改命令行和文件处理部分。
三、模块设计与实现
1. 文件组织
程序主要由三个文件组成:
| 文件 | 作用 |
|---|---|
main.py |
检查三个命令行参数,组织完整运行流程 |
file_utils.py |
读取论文,把两位小数结果写入答案文件 |
plagiarism_checker.py |
文本规范化、特征提取和重复率计算 |
项目规模不大,三个文件用函数来组织已经够用了,所以没有再单独设计类。主要调用过程是:
完整流程由 main() 组织:先调用 read_text() 读取两篇文章,再通过 calculate_similarity() 完成规范化、特征提取和相似度计算,最后调用 write_result() 将两位小数结果写入答案文件。文件处理和查重计算相互分开,调整算法时不需要修改输入输出部分。
三个命令行参数
↓
检查答案文件是否会覆盖输入文件
↓
读取原文和抄袭版
↓
文本规范化
↓
提取一元、二元、三元字符特征
↓
计算特征重合比例和余弦相似度
↓
组合重复率并写入答案文件
2. 文本规范化
两篇文章在比较前会先进行 Unicode NFKC 规范化,把英文转成小写,并去掉空格、换行和标点。这样“Hello”和“HELLO”不会因为全角半角不同就被当成两种内容。
对于局部词语变化,我没有直接维护同义词表。例如题目中的两句话:
原文:今天是星期日,天气晴。
改写:今天是周天,天气晴朗。
“星期日”和“周天”没有被强制替换成同一个词,而是由后面的多粒度字符特征进行比较。相同的上下文仍然计入重复,变化的局部会损失一部分分数。当前算法算出的重复率是 0.7679,不会因为只换了一个表达就直接变成 1.00。
3. 多粒度字符特征
设计时我考虑过使用中文分词,但这样要增加第三方依赖,而且分词结果也会影响查重。为了让程序在评测环境里更容易运行,最后采用字符 n-gram:
- 一元字符主要判断内容还保留了多少;
- 二元字符保留相邻字符关系;
- 三元字符补充更长一点的局部顺序。
三种特征的权重分别是 0.75、0.20 和 0.05。一元特征占得比较多,是因为在一句话中插入一个字时,附近多个二元、三元片段都会改变。如果连续片段权重太高,少量修改就可能让结果降得太快。
4. 相似度组合
第一版只使用余弦相似度。它计算起来简单,但拿样例测试后发现,增加和删除内容的结果仍然接近 0.99。原因是长文章即使删掉一部分,常用字符的整体频率方向还是很接近。
所以第二版加入了特征重合比例:将两边相同特征的频次取较小值,再除以较大的特征总数。新增或删除内容后,重合比例会跟着下降。最终结果为:
重复率 = 0.80 × 特征重合比例 + 0.20 × 余弦相似度
样例上的前后对比如下:
| 改写类型 | 第一版 | 调整后 |
|---|---|---|
| 增加内容 | 0.9928 | 0.8205 |
| 删除内容 | 0.9934 | 0.8082 |
| 轻度调序 | 0.9987 | 0.9831 |
| 中度调序 | 0.9953 | 0.9447 |
| 较明显调序 | 0.9887 | 0.8922 |
调整后,增删内容不再全部接近 1.00,调序程度增加时分数也会下降。不过这个算法主要比较字符及其局部顺序,对大幅同义改写的判断仍然有限。这是当前实现的边界,我没有为了某一个样例专门添加特殊规则。
四、性能分析与改进
程序原本已经能在 5 秒内完成,但作业要求使用性能分析工具找出瓶颈。我用 Python 自带的 cProfile 对原文和增加内容版本进行分析。这部分从运行 profile、修改代码到回归测试,实际大约用了 80 分钟。
一开始我以为 Unicode 规范化和移除标点最耗时,结果 profile 显示,真正比较明显的重复开销是 _validate_feature_vector()。组合算法会分别计算三种 n-gram 的特征重合比例,最后再计算余弦相似度,每次公开函数调用都重新扫描向量进行校验;已经规范化的文本也会再次进入规范化函数。


从函数耗时表可以看到,优化前 _validate_feature_vector() 调用了 8 次,累计耗时约 0.053 s,是当时比较明显的重复开销。
我做了两个调整:
- 原文和抄袭版各规范化一次,后面直接从规范化结果提取特征;
- 对外函数继续保留完整校验,
calculate_similarity()内部对自己刚生成的特征不再重复检查。
优化前后结果如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 函数调用次数 | 783,517 | 254,468 |
| 单次 profile 总耗时 | 0.134 s | 0.065 s |
calculate_similarity() 累计耗时 |
0.125 s | 0.057 s |
normalize_text() 调用次数 |
8 | 2 |
| 40 次平均耗时 | 80.313 ms | 43.412 ms |
优化后平均耗时减少约 45.9%。五组样例的重复率没有发生变化,说明这次优化减少的是重复工作,没有改变查重公式。优化后耗时最多的是 _extract_normalized_features(),它要实际遍历文本并生成 n-gram,属于算法本身必须完成的工作,所以暂时没有继续增加更复杂的结构。
五、单元测试与覆盖率
测试使用 Python 自带的 unittest,覆盖文件读写、规范化、特征提取、相似度和完整命令行流程,共有 71 个测试。
测试数据主要按白盒思路设计:先看每个判断和异常出口,再分别构造正常值、边界值和错误值。例如 n-gram 长度测试包含空列表、零、负数、非整数和比文本更长的窗口;文件模块则检查不存在、目录、空文件和正常 UTF-8 文件。
下面是几段计算模块测试:
def test_treats_local_expression_change_as_partial_match(self) -> None:
original = "今天是星期日,天气晴,我晚上要去看电影。"
changed = "今天是周天,天气晴朗,我晚上要去看电影。"
similarity = calculate_similarity(original, changed)
self.assertGreater(similarity, 0.7)
self.assertLess(similarity, 1.0)
def test_scores_related_text_higher_than_unrelated_text(self) -> None:
original = "今天是星期日天气晴"
related = "今天是周天天气晴朗"
unrelated = "计算机网络使用分层体系结构"
self.assertGreater(
calculate_similarity(original, related),
calculate_similarity(original, unrelated),
)
def test_local_order_changes_lower_the_score(self) -> None:
original = "天地玄黄宇宙洪荒日月盈昃辰宿列张"
disordered = "地天黄玄宙宇荒洪月日昃盈宿辰张列"
self.assertLess(calculate_similarity(original, disordered), 0.9)
最终覆盖率结果为:
| 文件 | 语句数 | 分支数 | 覆盖率 |
|---|---|---|---|
file_utils.py |
28 | 14 | 100% |
main.py |
26 | 4 | 100% |
plagiarism_checker.py |
92 | 32 | 100% |
| 合计 | 146 | 50 | 100% |

覆盖率达到 100% 只能说明这些语句和分支被执行过,不能直接证明算法对所有论文都准确。因此我还使用增、删、调序样例观察不同修改程度下的分数变化。
除了单元测试,我还使用 Ruff 检查常见代码问题。第一次检查主要发现了 import 排版和 Python 版本配置问题,调整后重新检查,结果为 All checks passed!。
六、异常处理
异常处理的目标是让错误输入尽早停止,并给出能看懂的提示,而不是生成一个看似正常的答案。当前博客展示的异常都有对应测试:
| 场景 | 设计目标 | 对应测试 |
|---|---|---|
| 参数数量错误 | 显示正确用法,返回状态码 2 | test_rejects_wrong_argument_count |
| 输入文件不存在 | 指明文件不存在,不生成答案 | test_reports_missing_original_file |
| 输入路径是目录 | 拒绝把目录当成论文读取 | test_rejects_directory_path |
| 输入文件为空 | 不对空内容计算重复率 | test_reports_empty_input_file |
| 规范化后无有效字符 | 避免空特征向量进入计算 | test_rejects_text_without_effective_content |
| 输出目录不存在 | 不自动把路径错误掩盖掉 | test_reports_missing_output_directory |
| 答案路径等于输入路径 | 防止覆盖原文或抄袭版 | test_does_not_overwrite_input_files |
以下是这些场景对应的测试用例节选,测试类中的临时目录初始化代码没有重复贴出:
def test_rejects_wrong_argument_count(self) -> None:
exit_code, error_message = self.run_main([])
self.assertEqual(exit_code, 2)
self.assertIn("用法", error_message)
def test_reports_missing_original_file(self) -> None:
missing_path = self.base_path / "missing.txt"
exit_code, error_message = self.run_main(
[str(missing_path), str(self.suspicious_path), str(self.answer_path)]
)
self.assertEqual(exit_code, 1)
self.assertIn("输入文件不存在", error_message)
def test_rejects_directory_path(self) -> None:
with self.assertRaises(IsADirectoryError):
read_text(self.base_path)
def test_reports_empty_input_file(self) -> None:
self.suspicious_path.write_text("", encoding="utf-8")
exit_code, error_message = self.run_main(
[str(self.original_path), str(self.suspicious_path), str(self.answer_path)]
)
self.assertEqual(exit_code, 1)
self.assertIn("没有有效内容", error_message)
def test_rejects_text_without_effective_content(self) -> None:
with self.assertRaises(ValueError):
normalize_text(",。!?")
def test_reports_missing_output_directory(self) -> None:
answer_path = self.base_path / "missing" / "answer.txt"
exit_code, error_message = self.run_main(
[str(self.original_path), str(self.suspicious_path), str(answer_path)]
)
self.assertEqual(exit_code, 1)
self.assertIn("输出目录不存在", error_message)
def test_does_not_overwrite_input_files(self) -> None:
exit_code, error_message = self.run_main(
[str(self.original_path), str(self.suspicious_path), str(self.original_path)]
)
self.assertEqual(exit_code, 1)
self.assertIn("不能覆盖", error_message)
七、Git 提交过程
我按功能完成情况逐步提交,没有等到最后再一次性上传。主要阶段如下:
| 阶段 | 提交 |
|---|---|
| 作业规划与 PSP 预估 | 3568f03 |
| 模块设计 | f81a802 |
| 文件读写 | 0715825 |
| 文本规范化 | eea64ee |
| 多粒度字符特征 | 7367afe |
| 基础重复率与组合算法改进 | 364518d、c9f8958 |
| 命令行程序 | ceb37f3 |
| 覆盖率和代码质量检查 | 2d96f56 |
| 性能优化 | 74a2c77 |
| 局部表达处理调整 | 950070f |
八、开发后的 PSP 记录
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | — | — |
| · Estimate | · 估计任务需要的时间 | 20 | 20 |
| Development | 开发 | — | — |
| · Analysis | · 需求分析,包括学习相关技术 | 70 | 70 |
| · Design Spec | · 生成设计文档 | 50 | 25 |
| · Design Review | · 设计复审 | 30 | 15 |
| · Coding Standard | · 制定代码规范 | 25 | 20 |
| · Design | · 具体设计 | 80 | 60 |
| · Coding | · 具体编码 | 210 | 250 |
| · Code Review | · 代码复审 | 50 | 55 |
| · Test | · 测试、修改代码和覆盖率检查 | 180 | 195 |
| Reporting | 报告 | — | — |
| · Test Report | · 测试报告 | 70 | 70 |
| · Size Measurement | · 计算工作量 | 20 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结与改进计划 | 50 | 45 |
| 合计 | 855 | 840 |
总时间和预估只差了 15 分钟,但具体分配并不准。设计文档比想象中快,真正超出预估的是编码和测试。第一版算法跑得快却不能很好反映增删内容,后来调整公式、重新跑样例和补回归测试,用掉了原本没有充分估计的时间。
这次 3 个程序文件共 275 行,3 个测试文件共 557 行。测试代码比程序代码多,主要是文件、参数和特征向量有不少边界情况。下次做类似任务时,我会把“真实样例调整”和“覆盖率补漏”单独估时,不再全部放进编码和测试两个大项。
九、总结
这次最后做出来的是一个基于字符 n-gram、特征重合比例和余弦相似度的查重程序。第一版结果几乎都在 0.99 左右时,我才发现“程序能算出结果”和“结果看起来合理”还是两回事。后面根据样例调整算法,再跑测试和性能分析,这个过程比公式本身更有收获。
程序目前只使用 Python 标准库,在我的 macOS 和 Python 3环境中完成了测试。Windows 10 还没有实际运行过,对大幅同义改写的识别也比较有限,这两点是当前没有覆盖到的地方。
浙公网安备 33010602011771号