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

作业 GitHub 链接:https://github.com/HoshisakiNeko/3124004470

0. 程序一览

项目 内容
语言 / 环境 Python 3.8+(开发环境 Python 3.11,Windows 10 x64),只用标准库
入口 main.py,调用方式 python main.py <原文> <抄袭版> <答案>
核心算法 字符二元组词频向量 + 余弦相似度
样例结果 orig_add.txt 0.87 / orig_del.txt 0.85 / orig_edit.txt 0.95 / orig_rewrite.txt 0.10
性能 1 MB × 2 的中文长文本约 1.2 秒,峰值内存约 85 MB(限制 5 秒 / 2048 MB)
测试 65 个单元测试用例,plagiarism 包语句覆盖率 100%

一、PSP 表格(开发前的预估)

PSP2.1 Personal Software Process Stages 预估耗时(分钟)
Planning 计划 30
· Estimate · 估计这个任务需要多少时间 30
Development 开发 610
· Analysis · 需求分析(包括学习新技术) 60
· Design Spec · 生成设计文档 45
· Design Review · 设计复审 30
· Coding Standard · 代码规范(为目前的开发制定合适的规范) 20
· Design · 具体设计 60
· Coding · 具体编码 180
· Code Review · 代码复审 45
· Test · 测试(自我测试,修改代码,提交修改) 170
Reporting 报告 90
· Test Report · 测试报告 40
· Size Measurement · 计算工作量 15
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 35
合计 730

预估时的基本判断:算法本身只有三步(读取 → 特征化 → 度量),编码量不大;
真正花时间的是"预处理的各种编码格式""异常处理""单元测试与覆盖率"
以及"性能分析",所以把一半以上的时间预算给了测试和报告。


二、计算模块接口的设计与实现过程

2.1 模块划分

程序按"输入 → 处理 → 输出"分层,一共 4 个模块、互相之间只通过少量函数调用
耦合,方便单独做单元测试:

模块 职责 对外接口
plagiarism/textio.py 文件读取与文本归一化 read_text(path) -> (文本, 编码)normalize_text(raw) -> str
plagiarism/similarity.py 核心计算模块:特征提取与相似度度量 count_shingles(text, ngram)cosine_similarity(a, b)compare_texts(a, b, ngram, metric)
plagiarism/cli.py 命令行解析、流程编排、答案写出 parse_args(argv)run(settings)main(argv)
plagiarism/errors.py 异常体系 PlagiarismCheckError 及其 6 个子类

调用关系(cli.run 是唯一的编排者,其余模块互不感知对方的存在):

flowchart TD A[main.py 入口] --> B[cli.parse_args 解析 3 个路径] B --> C[textio.read_text 读取原文] B --> D[textio.read_text 读取抄袭版] C --> E[textio.normalize_text 归一化] D --> F[textio.normalize_text 归一化] E --> G[similarity.compare_texts] F --> G G --> H[cli.write_answer 写出两位小数] B -.参数/文件错误.-> I[errors 异常 → 返回码 2/3] C -.读取/解码错误.-> I D -.读取/解码错误.-> I

一句话版本:main.py → cli.run → (textio, similarity) → 答案文件
任何一层出错都抛 PlagiarismCheckError 的子类,由 cli.main 统一转成
返回码,绝不产生"未捕获异常退出"。

2.2 算法关键:为什么是"字符二元组 + 余弦相似度"

第一步,归一化。 查重关心的是内容而不是排版,所以先把
NFKC(全角转半角)、casefold(统一大小写)做掉,再一次性删除所有
空白与标点,只保留文字字符:

_NOISE_PATTERN = re.compile(r"[\W_]+", re.UNICODE)   # \w 在 Python 3 中含汉字

def normalize_text(raw_text: str) -> str:
    if not raw_text:
        return ""
    normalized = unicodedata.normalize("NFKC", raw_text).casefold()
    return _NOISE_PATTERN.sub("", normalized)

只用两次 C 层调用就完成全部清洗,比逐字符判断快一个数量级:1 MB 文本
的归一化耗时约 0.07 秒。

第二步,切分与特征化。 中文没有天然词边界。用分词工具需要额外词典
(分发包变大,且词典未覆盖的专业词会被切错),所以这里选择字符
二元组
:把"论文查重"变成 论文/文查/查重 三个特征。二元组既保留了
局部语序信息(比单词频更细),又不会像三元组那样过于稀疏。

为了省内存,特征不去拼接子字符串,而是编码成整数:Unicode 码点最大
0x10FFFF(21 位),于是二元组可以用 (左码点 << 21) | 右码点 精确
表示,不存在哈希冲突:

def iter_shingle_keys(codepoints: array, ngram: int) -> Iterator[int]:
    if ngram <= 1:
        return iter(codepoints)
    if ngram == 2:
        return map(
            or_,
            map(lshift, codepoints, repeat(_CODEPOINT_BITS)),
            islice(codepoints, 1, None),
        )
    return _polynomial_keys(codepoints, ngram)   # n>=3 时用 64 位滚动哈希

第三步,度量。 把两篇论文都变成"n-gram → 出现次数"的稀疏向量后,
用余弦相似度衡量方向差异:

def cosine_similarity(left, right):
    if not left or not right:
        return 0.0
    if len(left) > len(right):          # 只遍历较小的向量做点积
        left, right = right, left
    lookup = right.get
    dot = 0
    for key, value in left.items():
        other = lookup(key)
        if other is not None:
            dot += value * other
    if dot == 0:
        return 0.0
    return min(1.0, dot / (_l2_norm(left) * _l2_norm(right)))

选择余弦而不是编辑距离 / 最长公共子序列的理由:

  • 长度鲁棒:把一篇论文节选一半,向量方向几乎不变,分数不会剧烈波动;
    编辑距离则会把"删掉一段"直接算成巨大差异;
  • 语义合理:只共享少数特征的文本夹角接近 90°,分数接近 0;
    高度重合的文本夹角接近 0°,分数接近 1;
  • 复杂度低:只需 O(|A| + |B|) 遍历两个稀疏向量,而编辑距离是 O(n²)
    几十万字的论文根本无法在 5 秒内算完。

2.3 独到之处

  1. 零第三方依赖的自适应实现:不装分词库、不装科学计算库,
    pip install -r requirements.txt 是空操作,程序在任何标准 Python 3
    环境里都能直接跑,也避免了"读词典文件"带来的额外文件访问。
  2. 精确的整数编码特征:二元组用 21 位位移精确编码(可证明无冲突),
    三元组及以上才退化为 64 位滚动哈希——兼顾了准确性与内存。
  3. 多编码自动识别utf-8-sig / utf-16 / gb18030 / big5 依次尝试,
    同学用记事本另存为 ANSI 的 GBK 文件也能正确读取,而不会拿乱码去算分。
  4. 短文本自适应:当文本短于 n-gram 长度时自动收缩(单字文本退化为
    字符频率比较),保证极端输入也有定义良好的结果,而不是崩溃或恒为 0。
  5. 指标可切换:除了默认的余弦相似度,还实现了 jaccard(集合重合度)
    containment(包含率,专门对付"原文 + 大段新增")。用同一批数据
    交叉验证,可以判断某个结论到底是不是算法选择造成的。

三、计算模块接口部分的性能改进

3.1 记录:性能改进花了多少时间

性能分析与改进合计约 55 分钟:先用 cProfile 定位瓶颈(约 20 分钟),
然后做了三轮改写与复测(约 25 分钟),最后写出可复现的基准脚本
(约 10 分钟)。

3.2 改进思路(三代实现)

版本 做法 1 MB 文本耗时 峰值内存
v1 朴素版 text[i:i+2] 拼字符串 + 手工 dict.get 累加 0.46 s 99.7 MB
v2 整数键版 整数编码 n-gram,但生成器表达式 + 多余的 dict() 复制 0.56 s 112.9 MB
v3 当前实现 utf-32-le 取码点 + map/operator 计数 + Counter 直接返回 + map(mul) 求范数 0.41 s 85.4 MB

(数据由 python tools/benchmark.py 1000000 生成,见 docs/benchmark.txt
时间与内存分两轮测量,因为 tracemalloc 会放大耗时 5~10 倍。)

三个关键改进点:

  1. 不生成中间字符串。v1 每次切分都要新建一个小字符串对象;
    v3 把文本一次性转成 array('I') 码点数组,特征只以整数形式存在,
    内存下降约 14%,同时也少了字符串哈希的开销。

  2. 把循环留在 C 层Counter 的计数循环是 C 实现
    _collections._count_elements),因此把"生成特征"也交给
    map(lshift) + islice + or_ 这套 C 层组合,比 Python 层的
    for 循环或生成器表达式更快:

    from collections import Counter
    from itertools import islice, repeat
    from operator import lshift, or_
    
    Counter(map(or_,
                map(lshift, codepoints, repeat(21)),   # 左码点 << 21
                islice(codepoints, 1, None)))          # 右码点
    
  3. 去掉一切"顺手但多余"的复制与重复遍历Counter 本身就是
    dict 的子类,再套一层 dict(...) 等于把上百万个键值对复制一遍
    (v2 的内存峰值比 v3 高约 32% 正是这个原因);L2 范数从
    sum(v*v for v in values) 改成 sum(map(mul, values, values)) 之后,
    实测从 0.065 s 降到 0.033 s。

3.3 性能分析图与消耗最大的函数

用标准库的 cProfile 采集一次完整流程(读取 → 归一化 → 特征提取 →
相似度 → 写出答案),语料为两个 100 万字符的中文文件:

论文查重程序的 cProfile 性能分析图

上图由 python tools/profile_report.py 1000000 自动生成(同时输出矢量图
docs/profile_chart.svg 与原始数据 docs/profile_data.json
完整调用栈见 docs/profile_stats.txt),语料为两个 100 万字符的中文文件。

按"自身耗时(tottime)"排序,消耗最大的函数是:

排名 函数 自身耗时 说明
1 _collections._count_elementsCounter 计数循环) 0.76 s(47%) 2 × 100 万次特征计数,纯 Python 里已经无法再优化,这是当前实现的性能天花板
2 similarity.cosine_similarity 0.36 s(22%) 稀疏向量点积的 Python 循环
3 dict.get 0.31 s(19%) 点积时的 94 万次查表
4 builtins.sum(L2 范数) 0.08 s 已用 map(mul) 优化到原来的一半
5 re.Pattern.sub(文本归一化) 0.04 s 预处理整体只占约 5%

结论与后续可做的改进:

  • 瓶颈已经集中在特征计数与点积这两个"必须访问每个字符一次"的环节
    预处理(读取、归一化、编码识别)合计不到 10%,继续优化它的收益很低;
  • 真实耗时(不带 profiler)为 1.2 秒,距离 5 秒上限还有 4 倍余量,
    峰值内存 85 MB 仅为 2048 MB 上限的 4%,因此不再引入复杂度更高的
    优化(例如 MinHash 抽样或分块并行),以免牺牲准确率;
  • 如果测试数据继续变大,下一步的优先级应当是:① 用 __slots__
    array 承载点积中间结果;② 对超过 10 MB 的文本按段落分块后取加权平均;
    ③ 在有多核的评测机上用 multiprocessing 并行计算两篇文本的特征。

四、计算模块部分单元测试展示

4.1 测试组织

测试用标准库 unittest 编写,共 65 个用例,分为 4 个文件:

文件 用例数 关注点
tests/test_similarity.py 20 核心算法:编码正确性、余弦公式、对称性、单调性、边界(空文本/单字/随机文本)
tests/test_textio.py 13 编码识别(UTF-8 BOM / UTF-16 / GB18030 / 二进制)、归一化、异常翻译
tests/test_cli.py 27 端到端流程、返回码、输出格式、可选参数、只读写给定文件、控制台编码、大文本性能
tests/test_errors.py 5 异常继承关系与返回码约定

运行方式:

python -m unittest discover -s tests -t . -v
# 结果:Ran 65 tests in 0.42s / OK

4.2 用白盒方法设计测试用例

(1)验证"整数编码无冲突"——这是整个算法最关键的假设。用朴素实现
(直接拼接字符串)作为"标准答案"逐项对照,覆盖 ngram = 1/2/3/4

def test_bigram_encoding_matches_naive_counting(self) -> None:
    text = normalize_text("数据 结构 与 算法,Data Structures & Algorithms 2024。")
    for ngram in (1, 2, 3, 4):
        with self.subTest(ngram=ngram):
            self.assertEqual(
                len(count_shingles(text, ngram)),
                len(_naive_counts(text, ngram)),
            )

def test_count_shingles_frequencies_are_correct(self) -> None:
    """出现次数也应与朴素实现一致,而不仅仅是没有冲突。"""
    text = normalize_text("abcabc")
    counts, naive = count_shingles(text, 2), _naive_counts(text, 2)
    self.assertEqual(sorted(counts.values()), sorted(naive.values()))
    self.assertEqual(sum(counts.values()), len(text) - 1)

(2)验证余弦公式本身(按定义手工算一遍,防止"看起来合理"的实现错误):

def test_cosine_matches_manual_formula(self) -> None:
    left = count_shingles(normalize_text("论文查重算法实现"), 2)
    right = count_shingles(normalize_text("论文查重方法实现"), 2)
    keys = set(left) & set(right)
    dot = sum(left[key] * right[key] for key in keys)
    manual = dot / (math.sqrt(sum(v * v for v in left.values()))
                    * math.sqrt(sum(v * v for v in right.values())))
    self.assertAlmostEqual(cosine_similarity(left, right), manual, places=12)

(3)构造"增 / 删 / 改"三类抄袭文本(黑盒测试数据的设计思路):

  • 完全复制 → 期望 1.00
  • 只改动标点、空格、全角半角 → 期望 1.00(归一化后应当完全相同);
  • 逐句换词改写(orig_edit.txt)→ 期望 > 0.9
  • 删除一段(orig_del.txt)→ 期望 0.8 ~ 0.9
  • 主题完全不同的另一篇文章(orig_rewrite.txt)→ 期望 < 0.2
  • 题目给出的样例句(改词 + 调序)→ 期望落在 0.4 ~ 0.8
  • 空文件、单字文件、随机字符文本 → 只要求"不崩溃且落在 [0,1]"。
def test_more_shared_content_scores_higher(self) -> None:
    """共享内容越多,重复率单调不降——保证结果具有可解释性。"""
    original = normalize_text("论文查重是软件工程课程的重要实验内容。")
    close = normalize_text("论文查重是软件工程课程的重要实验内容,需要认真完成。")
    far = normalize_text("论文查重与数据库索引优化没有直接关系。")
    self.assertGreater(compare_texts(original, close), compare_texts(original, far))

4.3 测试覆盖率

覆盖率用标准库 trace 统计(python tools/coverage_report.py),
统计范围是 plagiarism 包(不含测试代码本身):

模块 语句数 覆盖率
plagiarism/cli.py 140 100%
plagiarism/errors.py 29 100%
plagiarism/similarity.py 94 100%
plagiarism/textio.py 36 100%
合计 299 100%

>>>>>> 标记的逐行标注结果已写入 docs/coverage/*.cover
(本博客正文插入的就是这些标注文件与终端输出的截图)。

4.4 对测试设计本身的评价

  • 优点:核心算法的关键假设(无编码冲突、公式正确)用"独立实现对照"
    验证,而不是只用自家实现自证;异常与边界路径(权限、目录、二进制文件、
    空文件、单字文本)都有专门用例;端到端用例直接调用与评测一致的命令行
    入口,能覆盖"参数个数、返回码、答案格式"这些评测真正会检查的点。
  • 局限:测试用例的"标准答案"目前只有区间断言(例如 0.4 ~ 0.8),
    没有官方期望值可供回归比对,因此准确率只能靠人工审查样例结果是否
    符合直觉;另外随机文本只做了 30 组,属于抽样而非穷举。
  • 后续改进:把课堂下发的真实样例(orig.txt 与各 orig_add*.txt
    固化成回归测试的期望值表,一旦算法或参数调整,就能立刻发现结果漂移。

五、计算模块部分异常处理说明

所有异常都定义在 plagiarism/errors.py,继承同一个基类
PlagiarismCheckError,并通过 exit_code 声明返回码:

class PlagiarismCheckError(Exception):
    exit_code = 1                      # 未预期的内部错误

class InvalidArgumentError(PlagiarismCheckError):
    exit_code = 2                      # 命令行参数错误

class FileAccessError(PlagiarismCheckError):
    exit_code = 3                      # 文件相关错误

这样 cli.main 只用一个 except PlagiarismCheckError 就能保证
任何已知错误都不会让程序"异常退出"(评测规则中每条 -2 分):

try:
    report = run(settings)
except PlagiarismCheckError as exc:
    print(f"运行失败:{exc}", file=sys.stderr)
    return exc.exit_code
except Exception as exc:               # 最后一道防线
    print(f"未预期的内部错误:{exc}", file=sys.stderr)
    return 1
异常 设计目标(对应场景) 单元测试样例
InvalidArgumentError 参数个数不是 3 个、路径为空、答案文件与输入文件相同、--ngram 0在写任何文件之前就报错退出,避免破坏输入数据 test_missing_arguments_return_code_twotest_answer_path_equal_to_input_is_rejectedtest_invalid_ngram_is_rejected
SourceFileMissingError 题目给的路径写错 / 文件被移走:提示"文件不存在",返回码 3 test_read_missing_file_raisestest_missing_input_file_returns_code_three
PathIsDirectoryError 误把目录当文件传入:明确区分"目录"和"文件",而不是抛出 IsADirectoryError test_read_directory_raises
FileNotReadableError 文件存在但没有读取权限 / 被独占锁定:把 PermissionErrorOSError 翻译成统一异常 test_read_permission_denied_is_translatedtest_read_os_error_is_translated
TextDecodingError 二进制文件或未知编码:四种候选编码全部失败时宁可不输出,也不拿乱码算出一个假分数 test_read_undecodable_bytes_raisestest_undecodable_input_returns_code_three
OutputWriteError 答案文件所在目录不存在 / 无写权限:明确提示"答案文件写入失败",且不擅自创建目录(避免违反"不得读写其他文件"的要求) test_output_directory_missing_returns_code_threetest_write_answer_translates_oserror

典型测试代码(验证"权限不足"这一条分支,用 mock 精确触发异常路径:
白盒测试中"难以自然构造的输入"用打桩手段覆盖):

def test_read_permission_denied_is_translated(self) -> None:
    """权限不足时翻译成 FileNotReadableError(用 mock 触发该分支)。"""
    path = self.write_file("locked.txt", "论文查重".encode("utf-8"))
    with mock.patch.object(Path, "read_bytes", side_effect=PermissionError("拒绝访问")):
        with self.assertRaises(FileNotReadableError) as context:
            read_text(path)
    self.assertIn("没有读取权限", context.exception.message)

另外,空文件不算异常:原文或抄袭版为空时直接输出 0.00
compare_texts 的空向量快速路径),因为这属于"合法的极端输入",
评测时应当给出答案而不是报错。


六、实际 PSP 表格(开发完成后填写)

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 25
· Estimate · 估计这个任务需要多少时间 30 25
Development 开发 610 655
· Analysis · 需求分析(包括学习新技术) 60 50
· Design Spec · 生成设计文档 45 40
· Design Review · 设计复审 30 25
· Coding Standard · 代码规范 20 15
· Design · 具体设计 60 70
· Coding · 具体编码 180 200
· Code Review · 代码复审 45 50
· Test · 测试(自我测试,修改代码,提交修改) 170 205
Reporting 报告 90 95
· Test Report · 测试报告 40 45
· Size Measurement · 计算工作量 15 10
· Postmortem & Process Improvement Plan · 事后总结,并提出过程改进计划 35 40
合计 730 775

偏差分析:编码与实际测试分别超预估 20 分钟和 35 分钟,主要原因是
性能改进引入了两轮返工(整数编码方案重写、L2 范数优化),
以及为了让覆盖率从 95.5% 提升到 100% 又补了若干针对异常分支的
打桩用例(模拟权限不足、reconfigure 失败、未预期异常等);
需求分析和代码规范两项比预估顺利,共节省约 15 分钟。


七、如何复现本文的所有数据

# 单元测试:65 个用例(0.42 秒)
python -m unittest discover -s tests -t . -v

# 覆盖率:100%(输出 docs/coverage/ 与 docs/coverage_report.txt)
python tools/coverage_report.py

# 性能分析:生成 docs/profile_stats.txt 与 docs/profile_chart.svg
python tools/profile_report.py 1000000

# 三代实现对比:生成 docs/benchmark.txt
python tools/benchmark.py 1000000

# 样例批量运行:生成 docs/samples_report.txt
python tools/run_samples.py

# 与评测一致的一次调用(结果写入 ans.txt)
python main.py sample_data\orig.txt sample_data\orig_add.txt ans.txt

说明:作业要求中提到的性能分析工具是 VS 2017 / JProfiler(分别对应
C++ / Java)。本作业用 Python 完成,因此使用 Python 生态中等价的
cProfile + pstats(标准库自带,无需安装),分析图的生成脚本
同样是标准库实现,任何一台机器都能复现出完全相同的图。

posted @ 2026-09-15 22:15  星咲ねこ  阅读(6)  评论(0)    收藏  举报