论文查重程序(第一次正式软工作业)

GitHub 项目地址

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

一、开发前的 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.750.200.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,是当时比较明显的重复开销。

我做了两个调整:

  1. 原文和抄袭版各规范化一次,后面直接从规范化结果提取特征;
  2. 对外函数继续保留完整校验,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
基础重复率与组合算法改进 364518dc9f8958
命令行程序 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 还没有实际运行过,对大幅同义改写的识别也比较有限,这两点是当前没有覆盖到的地方。

posted @ 2026-09-15 17:40  何卓文  阅读(9)  评论(0)    收藏  举报