第一次个人编程作业:论文查重
作业 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 是唯一的编排者,其余模块互不感知对方的存在):
一句话版本: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 独到之处
- 零第三方依赖的自适应实现:不装分词库、不装科学计算库,
pip install -r requirements.txt是空操作,程序在任何标准 Python 3
环境里都能直接跑,也避免了"读词典文件"带来的额外文件访问。 - 精确的整数编码特征:二元组用 21 位位移精确编码(可证明无冲突),
三元组及以上才退化为 64 位滚动哈希——兼顾了准确性与内存。 - 多编码自动识别:
utf-8-sig / utf-16 / gb18030 / big5依次尝试,
同学用记事本另存为 ANSI 的 GBK 文件也能正确读取,而不会拿乱码去算分。 - 短文本自适应:当文本短于 n-gram 长度时自动收缩(单字文本退化为
字符频率比较),保证极端输入也有定义良好的结果,而不是崩溃或恒为 0。 - 指标可切换:除了默认的余弦相似度,还实现了
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 倍。)
三个关键改进点:
-
不生成中间字符串。v1 每次切分都要新建一个小字符串对象;
v3 把文本一次性转成array('I')码点数组,特征只以整数形式存在,
内存下降约 14%,同时也少了字符串哈希的开销。 -
把循环留在 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))) # 右码点 -
去掉一切"顺手但多余"的复制与重复遍历。
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 万字符的中文文件:

上图由
python tools/profile_report.py 1000000自动生成(同时输出矢量图
docs/profile_chart.svg与原始数据docs/profile_data.json,
完整调用栈见docs/profile_stats.txt),语料为两个 100 万字符的中文文件。
按"自身耗时(tottime)"排序,消耗最大的函数是:
| 排名 | 函数 | 自身耗时 | 说明 |
|---|---|---|---|
| 1 | _collections._count_elements(Counter 计数循环) |
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_two、test_answer_path_equal_to_input_is_rejected、test_invalid_ngram_is_rejected |
SourceFileMissingError |
题目给的路径写错 / 文件被移走:提示"文件不存在",返回码 3 | test_read_missing_file_raises、test_missing_input_file_returns_code_three |
PathIsDirectoryError |
误把目录当文件传入:明确区分"目录"和"文件",而不是抛出 IsADirectoryError |
test_read_directory_raises |
FileNotReadableError |
文件存在但没有读取权限 / 被独占锁定:把 PermissionError、OSError 翻译成统一异常 |
test_read_permission_denied_is_translated、test_read_os_error_is_translated |
TextDecodingError |
二进制文件或未知编码:四种候选编码全部失败时宁可不输出,也不拿乱码算出一个假分数 | test_read_undecodable_bytes_raises、test_undecodable_input_returns_code_three |
OutputWriteError |
答案文件所在目录不存在 / 无写权限:明确提示"答案文件写入失败",且不擅自创建目录(避免违反"不得读写其他文件"的要求) | test_output_directory_missing_returns_code_three、test_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(标准库自带,无需安装),分析图的生成脚本
同样是标准库实现,任何一台机器都能复现出完全相同的图。

浙公网安备 33010602011771号