论文查重程序
论文查重 —— 软件工程第一次个人编程作业
GitHub 仓库:https://github.com/Lin-huaj/Lin-huaj/tree/main/3124004213
学号:3124004213

写在前面
第一次个人编程作业的题目是论文查重。说实话刚看到这个题目的时候有点懵 ——"查重" 这个词听起来就很专业,知网、维普那些系统肯定不是一两天能做出来的。后来仔细读了需求才明白,核心就是计算两篇论文的重复率,输出一个 0 到 1 之间的浮点数。这样一想,思路就清晰多了。
最终选择的方案是 2-gram 词频向量 + 余弦相似度,没有用 jieba 分词 —— 因为 2-gram 不依赖词典,对中文文本直接按相邻两个字切分就行,实现更简单,也没有第三方依赖。整个项目用 Python 实现,代码量不算大,但麻雀虽小五脏俱全 —— 文件读写、文本清洗、特征提取、相似度计算、异常处理、单元测试都涉及到了。
一、项目概述
题目要求很简单:给定原文和抄袭版论文,计算重复率。
python main.py \[原文文件] \[抄袭版论文文件] \[答案文件]
答案文件里写一个两位小数的浮点数,比如 0.90。
看起来简单,但实际动手的时候发现坑还挺多的:标点符号的差异要不要管?空文本怎么处理?输出文件路径不存在怎么办?下面慢慢说。
二、PSP 表格
按照要求,先把预估时间填好,开发完再填实际时间。
| Personal Software Process Stages | 预估(分钟) | 实际(分钟) |
|---|---|---|
| Planning 计划 | 30 | 25 |
| ・Estimate・估计这个任务需要多少时间 | 30 | 25 |
| Development 开发 | 295 | 280 |
| ・Analysis・需求分析 (包括学习新技术) | 60 | 50 |
| ・Design Spec・生成设计文档 | 40 | 30 |
| ・Design Review・设计复审 | 20 | 20 |
| ・Coding Standard・代码规范 | 15 | 10 |
| ・Design・具体设计 | 45 | 40 |
| ・Coding・具体编码 | 90 | 85 |
| ・Code Review・代码复审 | 25 | 30 |
| ・Test・测试(自我测试,修改代码,提交修改) | 70 | 85 |
| Reporting 报告 | 75 | 80 |
| ・Test Report・测试报告 | 35 | 40 |
| ・Size Measurement・计算工作量 | 15 | 15 |
| ・Postmortem・事后总结 | 25 | 25 |
| 合计 | 400 | 385 |
实际比预估少了 15 分钟,主要是需求分析和设计阶段比预期顺利 ——2-gram 方案不需要研究分词库,上手比较快。但测试环节超了预估(70 → 85 分钟),因为补充异常处理用例(权限不足、输出路径无效等)比预期更费时,而且在代码复审中发现并删除了一个不可达的冗余分支,需要重新跑覆盖率。
三、代码设计
3.1 整体结构
项目比较小,所有核心逻辑都放在一个 main.py 里,测试单独放 tests/ 目录:
3124004213/
├── main.py # 入口 + 核心算法(文本清洗、2-gram、余弦相似度)
├── requirements.txt # 依赖说明(仅使用 Python 3 标准库)
├── README.md # 项目说明
└── tests/
├── test_main.py # 单元测试(18 个用例)
├── orig.txt # 测试原文(《活着》节选,约 29KB)
├── orig_0.8_add.txt # 抄袭版测试文本(插入杂字,约 34KB)
└── ans.txt # 样例答案
3.2 为什么用单文件
一开始我也考虑过拆成多个模块(比如 text_processor.py、similarity.py),但写着写着发现整个项目的核心函数也就 7 个,代码量不到 200 行。拆成多个文件反而增加了 import 的复杂度,而且评测的时候只需要提交一个 main.py 作为入口,单文件更方便。
不过虽然是单文件,内部还是做了清晰的分层:
-
核心计算层:
read_file、preprocess、get_ngrams、cosine_similarity、compute_similarity—— 负责从 "文本" 到 "相似度" 的全部计算,与文件系统、命令行解耦 -
入口控制层:
write_answer、main—— 负责命令行参数解析、异常处理、写结果文件
这样做的好处是单元测试可以直接 import 核心函数,不需要经过命令行,测试起来很方便。
3.3 核心流程图
┌──────────────────────────────────────────────────────┐
│ main(argv) │
│ 解析命令行: \[原文路径] \[抄袭版路径] \[答案路径] │
│ 参数个数 != 3 ? → 打印用法,返回 1 │
└────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 【第一阶段】compute_similarity(orig, copy) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ read_file() │ │ read_file() │ │
│ │ (原文) │ │ (抄袭版) │ │
│ └──────┬───────┘ └───────┬──────┘ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ preprocess() │ │ preprocess() │ │
│ │ 去标点空白 │ │ 去标点空白 │ │
│ └──────┬───────┘ └───────┬──────┘ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ cosine_similarity(text1, text2) │ │
│ │ get_ngrams ×2 → Counter 词频向量 │ │
│ │ 遍历较短向量求交集点积 / (|v1| × |v2|) │ │
│ └───────────────────────┬──────────────────────────┘ │
│ │ │
│ 文件不存在 ? → FileNotFoundError → 返回 1 │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 【第二阶段】write_answer(out, ratio) │
│ 写入 f"{ratio:.2f}" │
│ │
│ 权限不足 ? → PermissionError → 返回 1 │
│ 其他IO错误 ? → OSError → 返回 1 │
│ 成功 → 返回 0 │
└──────────────────────────────────────────────────────┘
3.4 算法选择:为什么是 2-gram + 余弦相似度
做查重其实有很多算法可以选:
| 算法 | 优点 | 缺点 |
|---|---|---|
| 2-gram + 余弦相似度 | 不依赖分词词典,实现简单,对局部增删改鲁棒 | 对同义词替换不敏感 |
| jieba 分词 + 余弦相似度 | 分词更精确,词频语义性强 | 需加载词典,首次运行慢,有第三方依赖 |
| SimHash | 性能好,适合大数据量 | 精度较粗,短文本效果差 |
| 编辑距离 | 能检测细微差异 | 计算复杂度高,长文本慢 |
最终选了 2-gram + 余弦相似度,原因有几个:
-
不依赖分词词典——2-gram 直接按相邻两个字切分,不需要 jieba 那样加载几十 MB 的词典,启动快、无第三方依赖
-
对 "增删改" 型抄袭鲁棒—— 插入或删除一个字只会影响相邻的 2~3 个 2-gram,大部分特征仍然重合,重复率依然很高
-
实现简单—— 用 Python 的
Counter统计词频,几行代码就能算出余弦相似度 -
性能足够—— 测试样例 30KB 的文本只要 9 毫秒,远低于 5 秒限制
当然也有缺点:2-gram 只看相邻字符的组合,不看语义。所以同义词替换(比如 "星期天" 改成 "周天")会导致部分 2-gram 不匹配,重复率会比实际略低。不过对于这个作业来说,2-gram 已经够用了。
3.5 余弦相似度的计算
公式其实不复杂:
$sim(A,B) = \frac{\vec{A} \cdot \vec{B}}{|\vec{A}| \times |\vec{B}|}$
具体来说:
-
两篇论文分别做 2-gram 切分,统计每个 2-gram 的出现次数,得到两个 "词频向量"
-
算两个向量的点积(共同 2-gram 的频次乘积之和)
-
算两个向量各自的模长
-
点积除以模长的乘积,就是相似度
举个例子:原文是 "今天天气晴",抄袭版是 "今天天气晴朗"。
-
原文 2-gram:今天、天天、天气、气晴 → 词频各 1
-
抄袭版 2-gram:今天、天天、天气、气晴、晴朗 → 词频各 1
-
共同 2-gram:今天、天天、天气、气晴(4 个)
-
点积:1×1 + 1×1 + 1×1 + 1×1 = 4
-
原文模长:√(1+1+1+1) = 2
-
抄袭版模长:√(1+1+1+1+1) = √5 ≈ 2.236
-
相似度:4 / (2 × 2.236) ≈ 0.89
可以看到,即使抄袭版多了一个 "朗" 字,相似度仍然很高(0.89),这正是 2-gram 对局部增删改鲁棒的体现。
3.6 文本预处理的思路
预处理只做一件事:删除所有非中文、非字母、非数字的字符。
用的正则是 [^\u4e00-\u9fa5a-zA-Z0-9],匹配所有标点、空格、换行、特殊符号,替换为空串。
为什么这么做?因为抄袭版论文中常见的改动包括标点增删、空格调整、换行变化 —— 这些都不应该影响重复率。把它们全部删掉之后,"今天,天气晴!" 和 "今天天气晴" 在特征上就完全一致了。
另外一个细节是文件读取用了 errors="ignore"—— 如果文件中混入了非法的 UTF-8 字节(比如二进制乱码),程序不会崩溃,而是忽略这些坏字节,正常读取可读部分。
四、性能分析
4.1 cProfile 性能分析图
用 Python 自带的 cProfile 跑了性能分析(生成了 profile_result.prof 文件,可以用 snakeviz profile_result.prof 在浏览器中查看交互式火焰图),结果如下:

18642 function calls (18629 primitive calls) in 0.009 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.009 0.009 {built-in method builtins.exec}
1 0.000 0.000 0.007 0.007 main.py:150(main)
1 0.000 0.000 0.007 0.007 main.py:119(compute_similarity)
1 0.001 0.001 0.005 0.005 main.py:79(cosine_similarity) ← 消耗最大
3 0.001 0.000 0.002 0.001 {built-in method builtins.sum}
2 0.001 0.001 0.001 0.001 main.py:56(get_ngrams)
2 0.000 0.000 0.001 0.001 main.py:23(read_file)
1 0.000 0.000 0.001 0.001 main.py:138(write_answer)
从结果可以很明显地看出,**程序中消耗最大的函数是 **cosine_similarity(累计耗时 0.005 秒,约占整体 56%),其次是 get_ngrams(0.001 秒)。
值得注意的是,优化前 preprocess 曾是第二大热点(0.002 秒),预编译正则之后它已经从 top 热点列表中消失了。
整个程序对 30 KB 级文本仅耗时约 9 毫秒,远低于评测 "5 秒内给出答案" 的要求,内存占用为毫 MB 级。
4.2 改进思路与效果
第一次跑性能分析之后,针对热点做了三项优化:
-
预编译正则:
preprocess中每次调用re.sub都会经过一次正则编译,改成模块级re.compile之后彻底消除了重复编译开销 -
点积遍历较短向量:
cosine_similarity中点积循环改为始终遍历键数较少的 Counter,减少循环体执行次数 -
删除不可达冗余分支:原代码中
if mag1 == 0 or mag2 == 0: return 0.0在数学上不可达(Counter 非空时模长必然大于 0),删除后简化了控制流
另外还实验了第四项优化 —— 用 zip 切片替代逐字符切片生成 n-gram,实测可额外提速约 5.7%,但代码可读性下降明显。考虑到原代码性能已经在毫秒级、远低于 5 秒限制,最终保留了更易读的原始写法。
在同一台机器、同一语料上,对核心函数 compute_similarity 用 timeit 各跑 30 次取平均:
| 版本 | 平均耗时 | 输出答案 |
|---|---|---|
| 改进前 | 5.43 ms | 0.90 |
| 改进后(预编译正则 + 遍历短向量 + 删除冗余分支) | 5.38 ms | 0.90 |
优化后核心计算提速约 1.0%,输出结果完全一致。由于 Python 3 的正则与 Counter 本身已高度优化,且原代码性能已在毫秒级,提升空间有限。cProfile 端到端耗时从 10 ms 降至 9 ms。
4.3 真实样例结果
| 样例文件 | 重复率 | 说明 |
|---|---|---|
| orig_0.8_add.txt | 0.90 | 在原文基础上插入大量杂字 |
样例输出 0.90,与预期一致 —— 虽然抄袭版插入了很多杂字,但原文的核心 2-gram 特征大部分保留,重复率仍然很高。
五、单元测试
5.1 测试策略
测试用的是 pytest 框架 + pytest-cov 覆盖率插件。测试用例的设计思路是白盒测试—— 看着代码的逻辑,把每条路径都覆盖到:
-
正常情况:预处理正常、2-gram 切分正常、相似度正常算
-
边界情况:完全相同的文本(相似度 = 1)、完全不同的文本(相似度 = 0)、空文本、短文本、长度恰等于 n
-
异常情况:参数不足、文件不存在、输出路径无效、权限不足、非法 UTF-8 编码
-
性质测试:余弦相似度的对称性(sim (a,b) == sim (b,a))
-
集成测试:通过临时文件验证
compute_similarity文件级接口
一共写了 18 个测试用例。
5.2 测试用例一览
| # | 测试目标 | 被测函数 | 预期 |
|---|---|---|---|
| 1 | 删除中文 / 英文标点 | preprocess | 标点被清除 |
| 2 | 保留字母数字 | preprocess | 只保留字母数字 |
| 3 | 文本长度小于 n | get_ngrams | 返回整体 |
| 4 | 文本长度恰等于 n | get_ngrams | 返回整体 |
| 5 | 正常长度 2-gram 切分 | get_ngrams | 结果精确正确 |
| 6 | 空文本 | get_ngrams | 返回空列表 |
| 7 | 相同文本相似度 | cosine_similarity | = 1.0 |
| 8 | 无关文本相似度 | cosine_similarity | = 0.0 |
| 9 | 空文本算相似度 | cosine_similarity | = 0.0 不崩溃 |
| 10 | 部分重叠(增删改样例) | cosine_similarity | 0 < sim < 1 |
| 11 | 相似度对称性 | cosine_similarity | sim(a,b)==sim(b,a) |
| 12 | 文件级接口 | compute_similarity | 0 < sim ≤ 1 |
| 13 | 答案输出格式 | write_answer | "0.88"(两位小数) |
| 14 | 参数不足 | main | 退出码 1 |
| 15 | 文件不存在 | main | 退出码 1 |
| 16 | 输出路径目录不存在 | main | 退出码 1 |
| 17 | 答案文件只读(权限不足) | main | 退出码 1 |
| 18 | 非法 UTF-8 字节 | read_file | 忽略坏字节,正常读取 |
运行结果:18 passed in 0.22s,全部通过。
5.3 部分测试代码
边界值测试—— 确保算法在极端情况下也能给出正确答案:
def test_empty_text_returns_zero():
"""空文本不应导致除零或异常。"""
assert cosine_similarity("", "abc") == 0.0
assert cosine_similarity("abc", "") == 0.0
assert cosine_similarity("", "") == 0.0
def test_partial_overlap_between_zero_and_one():
"""构造'增删改'抄袭样例:星期天→周天(改)、天气晴→天气晴朗(增)。"""
a = preprocess("今天是星期天,天气晴,晚上看电影")
b = preprocess("今天是周天,天气晴朗,晚上去看电影")
sim = cosine_similarity(a, b)
assert 0.0 < sim < 1.0
异常测试—— 权限不足的场景,用 os.chmod 把答案文件设为只读:
def test_main_permission_denied_returns_1(monkeypatch, capsys, tmp_path):
"""答案文件为只读,应捕获 PermissionError 并返回 1。"""
import stat
orig = tmp_path / "orig.txt"
copy = tmp_path / "copy.txt"
orig.write_text("今天天气晴", encoding="utf-8")
copy.write_text("今天天气晴朗", encoding="utf-8")
read_only = tmp_path / "readonly.txt"
read_only.write_text("", encoding="utf-8")
os.chmod(str(read_only), stat.S_IREAD) # 设为只读
try:
monkeypatch.setattr(sys, "argv", \["main.py", str(orig), str(copy), str(read_only)])
rc = main()
assert rc == 1
assert "权限不足" in capsys.readouterr().out
finally:
os.chmod(str(read_only), stat.S_IWRITE | stat.S_IREAD)
5.4 测试覆盖率
用 pytest-cov 跑了覆盖率:

Name Stmts Miss Cover Missing
\---------------------------------------
main.py 54 1 98% 188
\---------------------------------------
TOTAL 54 1 98%
语句覆盖率 98%。唯一未覆盖的第 188 行是 if __name__ == "__main__": 启动块 —— 该块仅在直接运行脚本时触发,单元测试通过 import 方式调用模块,不会执行此块,已标注 # pragma: no cover。核心计算逻辑(read_file、preprocess、get_ngrams、cosine_similarity、compute_similarity、write_answer、main)的所有正常路径与异常分支均已覆盖。
5.5 测试够不够?
说实话 18 个用例对于这个小项目来说已经不少了。正常路径、边界值、异常路径、性质测试都覆盖了。如果要更完善的话,可以加:
-
超大文件测试(看看内存和性能上限)
-
更多编码测试(UTF-16、Latin-1 等)
-
并发测试(多个文件同时处理)
不过对于这个作业,目前的覆盖度已经够用了。
六、异常处理
程序可能遇到的异常,一共设计了 5 种处理方式:
6.1 命令行参数错误
什么时候会触发?用户漏传或多余传参。
处理方式:在 main() 开头检查 len(sys.argv) != 4,打印用法说明,返回退出码 1。
if len(sys.argv) != 4:
print("用法: python main.py <原文文件> <抄袭版文件> <答案文件>")
return 1
对应测试:test_main_wrong_arg_count_returns_1
6.2 文件不存在(FileNotFoundError)
什么时候会触发?原文或抄袭版文件的路径写错了,文件不存在。
处理方式:在第一阶段(计算相似度)的 try 块中捕获 FileNotFoundError,打印 "文件未找到" 及具体路径,返回退出码 1。
try:
ratio = compute_similarity(orig_path, copy_path)
except FileNotFoundError as e:
print(f"文件未找到: {e}")
return 1
对应测试:test_main_missing_file_returns_1
6.3 权限不足(PermissionError)
什么时候会触发?答案文件所在路径没有写入权限,或者答案文件本身是只读的。
处理方式:在第二阶段(写入答案)的 try 块中捕获 PermissionError,打印 "权限不足",返回退出码 1。
try:
write_answer(out_path, ratio)
except PermissionError as e:
print(f"权限不足,无法写入答案文件: {e}")
return 1
对应测试:test_main_permission_denied_returns_1
6.4 其他 IO 错误(OSError)
什么时候会触发?答案文件所在目录不存在、磁盘满等其他文件系统错误。
处理方式:在第二阶段的 try 块中捕获 OSESError(PermissionError 的父类,但因为 PermissionError 在前先匹配,所以这里只捕获其他 OSError),打印 "文件读写错误",返回退出码 1。
except OSError as e:
print(f"文件读写错误: {e}")
return 1
对应测试:test_main_output_path_invalid_returns_1
6.5 编码异常(非法 UTF-8 字节)
什么时候会触发?文件中混入了二进制乱码或非 UTF-8 编码的字节。
处理方式:read_file 使用 errors="ignore" 打开文件,无法解码的字节会被静默忽略,保证正常读到可读部分,不会导致程序崩溃。
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
对应测试:test_read_file_ignores_invalid_utf8
6.6 退出码设计
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 参数错误 / 文件不存在 / 权限不足 / IO 错误 |
注:本项目将所有异常统一返回退出码 1,因为评测脚本只关心 "成功还是失败"。如果需要更精细的错误分类,可以按参数错误 = 1、文件错误 = 2、写入错误 = 3 来区分。
七、事后总结
7.1 PSP 回顾
实际比预估少了 15 分钟(400 → 385),主要是需求分析和设计阶段比预期顺利 ——2-gram 方案不需要研究分词库,上手比较快。但测试环节超了预估(70 → 85 分钟),因为补充异常处理用例比预期更费时,而且在代码复审中发现并删除了不可达冗余分支,需要重新跑覆盖率。
7.2 踩过的坑
-
输出路径异常被输入异常抢先匹配:一开始把
compute_similarity和write_answer放在同一个 try 块里,结果输出路径目录不存在时open()抛出的也是FileNotFoundError,被输入文件的异常分支抢先匹配了,错误提示变成了 "文件未找到" 而不是 "文件读写错误"。后来改成分两阶段异常处理才解决。 -
不可达的冗余分支:原代码里写了
if mag1 == 0 or mag2 == 0: return 0.0,看起来是防御性编程,但实际上 Counter 非空时模长必然大于 0,这个分支永远不会执行。代码复审的时候才发现,删掉之后覆盖率从 95% 升到了 98%。 -
flake8 W292 警告:文件末尾忘了加换行符,flake8 报了 W292 警告。虽然不影响运行,但 Code Quality Analysis 要求消除所有警告,补上换行就好了。
7.3 如果重做
如果重新做这个项目,我会:
-
一开始就设计好异常处理的分层—— 而不是写完之后再改,避免输出异常被输入异常抢先匹配的问题
-
考虑更多算法对比—— 比如 SimHash 或者 jieba 分词,可能对同义词替换更敏感
-
先写测试再写代码——TDD 的方式可能会让边界条件考虑得更周全,而不是写完代码再补测试
7.4 收获
虽然只是个小项目,但从需求分析、代码设计、编码实现、单元测试到文档撰写,走了一遍完整的软件开发流程。PSP 表格也帮我养成了 "先估时间、后记实际" 的习惯,以后做项目会继续用。
另外一个收获是代码复审的价值—— 这次复审中发现了不可达冗余分支、flake8 警告、异常分层不合理等问题,都是写代码的时候没注意到的。以后写完代码一定要再过一遍。
八、使用说明
\# 无需安装第三方依赖(仅使用 Python 3 标准库)
\# 运行查重
python main.py \[原文文件] \[抄袭版论文文件] \[答案文件]
\# 示例
python main.py C:\tests\orig.txt C:\tests\orig_add.txt C:\tests\ans.txt
\# 运行单元测试
python -m pytest tests -v
\# 运行测试并查看覆盖率
python -m pytest tests --cov=main --cov-report=term-missing
\# 生成 HTML 覆盖率报告
python -m pytest tests --cov=main --cov-report=html
\# 性能分析(生成 .prof 文件)
python -m cProfile -o profile_result.prof main.py tests\orig.txt tests\orig_0.8_add.txt tests\ans.txt
\# 用 SnakeViz 查看性能分析火焰图(需先 pip install snakeviz)
snakeviz profile_result.prof

浙公网安备 33010602011771号