论文查重程序

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

GitHub 仓库https://github.com/Lin-huaj/Lin-huaj/tree/main/3124004213

学号:3124004213

image

写在前面

第一次个人编程作业的题目是论文查重。说实话刚看到这个题目的时候有点懵 ——"查重" 这个词听起来就很专业,知网、维普那些系统肯定不是一两天能做出来的。后来仔细读了需求才明白,核心就是计算两篇论文的重复率,输出一个 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.pysimilarity.py),但写着写着发现整个项目的核心函数也就 7 个,代码量不到 200 行。拆成多个文件反而增加了 import 的复杂度,而且评测的时候只需要提交一个 main.py 作为入口,单文件更方便。

不过虽然是单文件,内部还是做了清晰的分层:

  • 核心计算层read_filepreprocessget_ngramscosine_similaritycompute_similarity—— 负责从 "文本" 到 "相似度" 的全部计算,与文件系统、命令行解耦

  • 入口控制层write_answermain—— 负责命令行参数解析、异常处理、写结果文件

这样做的好处是单元测试可以直接 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 + 余弦相似度,原因有几个:

  1. 不依赖分词词典——2-gram 直接按相邻两个字切分,不需要 jieba 那样加载几十 MB 的词典,启动快、无第三方依赖

  2. 对 "增删改" 型抄袭鲁棒—— 插入或删除一个字只会影响相邻的 2~3 个 2-gram,大部分特征仍然重合,重复率依然很高

  3. 实现简单—— 用 Python 的 Counter 统计词频,几行代码就能算出余弦相似度

  4. 性能足够—— 测试样例 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}|}$

具体来说:

  1. 两篇论文分别做 2-gram 切分,统计每个 2-gram 的出现次数,得到两个 "词频向量"

  2. 算两个向量的点积(共同 2-gram 的频次乘积之和)

  3. 算两个向量各自的模长

  4. 点积除以模长的乘积,就是相似度

举个例子:原文是 "今天天气晴",抄袭版是 "今天天气晴朗"。

  • 原文 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 在浏览器中查看交互式火焰图),结果如下:

image

         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 改进思路与效果

第一次跑性能分析之后,针对热点做了三项优化:

  1. 预编译正则preprocess 中每次调用 re.sub 都会经过一次正则编译,改成模块级 re.compile 之后彻底消除了重复编译开销

  2. 点积遍历较短向量cosine_similarity 中点积循环改为始终遍历键数较少的 Counter,减少循环体执行次数

  3. 删除不可达冗余分支:原代码中 if mag1 == 0 or mag2 == 0: return 0.0 在数学上不可达(Counter 非空时模长必然大于 0),删除后简化了控制流

另外还实验了第四项优化 —— 用 zip 切片替代逐字符切片生成 n-gram,实测可额外提速约 5.7%,但代码可读性下降明显。考虑到原代码性能已经在毫秒级、远低于 5 秒限制,最终保留了更易读的原始写法

在同一台机器、同一语料上,对核心函数 compute_similaritytimeit 各跑 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 跑了覆盖率:

image

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_filepreprocessget_ngramscosine_similaritycompute_similaritywrite_answermain)的所有正常路径与异常分支均已覆盖。

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 块中捕获 OSESErrorPermissionError 的父类,但因为 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 踩过的坑

  1. 输出路径异常被输入异常抢先匹配:一开始把 compute_similaritywrite_answer 放在同一个 try 块里,结果输出路径目录不存在时 open() 抛出的也是 FileNotFoundError,被输入文件的异常分支抢先匹配了,错误提示变成了 "文件未找到" 而不是 "文件读写错误"。后来改成分两阶段异常处理才解决。

  2. 不可达的冗余分支:原代码里写了 if mag1 == 0 or mag2 == 0: return 0.0,看起来是防御性编程,但实际上 Counter 非空时模长必然大于 0,这个分支永远不会执行。代码复审的时候才发现,删掉之后覆盖率从 95% 升到了 98%。

  3. flake8 W292 警告:文件末尾忘了加换行符,flake8 报了 W292 警告。虽然不影响运行,但 Code Quality Analysis 要求消除所有警告,补上换行就好了。

7.3 如果重做

如果重新做这个项目,我会:

  1. 一开始就设计好异常处理的分层—— 而不是写完之后再改,避免输出异常被输入异常抢先匹配的问题

  2. 考虑更多算法对比—— 比如 SimHash 或者 jieba 分词,可能对同义词替换更敏感

  3. 先写测试再写代码——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

参考资料

posted @ 2026-09-15 23:44  forest132  阅读(6)  评论(0)    收藏  举报