第一次编程作业
第一次个人编程作业——论文查重
Github 作业链接:https://github.com/xxyyll0323/3224004083
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 学号 | 3224004083 |
| 入口程序 | 3224004083/main.py |
| 开发语言 | Python 3(零第三方运行时依赖) |
一、PSP 表格
「预估耗时」在动手写代码之前填写;「实际耗时」在整个项目做完之后回填。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 20 |
| · Estimate | · 估计这个任务需要多少时间 | 10 | 10 |
| Development | 开发 | ||
| · Analysis | · 需求分析(包括学习新技术) | 40 | 55 |
| · Design Spec | · 生成设计文档 | 30 | 30 |
| · Design Review | · 设计复审 | 15 | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 25 |
| · Design | · 具体设计 | 40 | 45 |
| · Coding | · 具体编码 | 120 | 150 |
| · Code Review | · 代码复审 | 30 | 35 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 100 | 160 |
| Reporting | 报告 | ||
| · Test Report | · 测试报告 | 40 | 45 |
| · Size Measurement | · 计算工作量 | 15 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 45 | 60 |
| 合计 | 520 | 665 |
预估是怎么来的:按"任务清单 → 逐项估时"推出来的,不是拍脑袋。最容易低估的是
Analysis —— 作业里有几条硬约束(5 秒内给出答案、内存不超过 2048 MB、不联网、
不读写命令行之外的文件、不能异常退出)会反过来决定算法选型和异常设计,必须先吃透。
偏差分析:
| 阶段 | 预估 | 实际 | 偏差 | 原因 |
|---|---|---|---|---|
| Analysis | 40 | 55 | +15 | 确认硬约束如何影响算法与异常设计 |
| Coding Standard | 15 | 25 | +10 | 引入 pylint 后回头统一命名与文档规范 |
| Coding | 120 | 150 | +30 | 多阶 n-gram 的权重反复试了几轮 |
| Test | 100 | 160 | +60 | 为把覆盖率补到 100%,额外覆盖异常分支与进程入口 |
| Postmortem | 45 | 60 | +15 | 性能分析发现瓶颈不在算法而在规范化,多花时间做对照实验 |
| 合计 | 520 | 665 | +145 | 超支主要在两块:把边界想全、用数据确认优化方向 |
真正花时间的不是"写出能跑的代码"(Coding 只占 1/4),而是把边界情况和性能问题用数据
确认清楚。PSP 的价值就在这里:预估时我明显低估了测试与性能分析的成本。
二、计算模块接口的设计与实现
2.1 代码组织
程序入口是 3224004083/main.py,核心计算放在 plagiarism/ 包里,按职责拆成五个模块:
| 模块 | 函数 / 类 | 作用 |
|---|---|---|
main.py |
main(argv) |
接收命令行参数,串起整个流程;把可预期错误转成退出码 |
configure_output_streams() |
让标准输出/错误在 Windows 控制台也能打印中文 | |
EXIT_OK / EXIT_RUNTIME_ERROR / EXIT_USAGE_ERROR |
退出码 0 / 1 / 2 | |
plagiarism/reader.py |
read_text(path) |
读文件:依次尝试 UTF-8 / GB18030 / UTF-16,去 BOM,做 NFKC |
strip_html(text) |
剥离 <script> / <style> 与所有 HTML 标签、解码实体 |
|
plagiarism/normalize.py |
normalize(text) |
规范化:只保留字母数字汉字,字母统一小写 |
normalize_reference(text) |
逐字符参考实现,仅用于测试与基准对照 | |
plagiarism/similarity.py |
count_ngrams(text, n) |
统计相邻 n 个字符的片段各出现多少次 |
cosine_similarity(a, b) |
两个频次向量的夹角余弦 | |
ngram_similarity(a, b) |
1~4 阶余弦的加权平均 | |
compute_similarity(p1, p2) |
读两个文件 → 规范化 → 计算,模块对外的总入口 | |
explain(a, b) |
返回逐阶明细,便于调试 | |
_select_orders(n) |
超长文本的降阶策略 | |
plagiarism/writer.py |
format_score(score) |
保留两位小数、四舍五入、越界夹紧 |
write_answer(path, score) |
把答案写进文件 | |
plagiarism/errors.py |
PaperCheckError 及三个子类 |
可预期错误的异常体系 |
为什么这样分:文件读写(reader / writer)与算法(normalize / similarity)
彻底分开。算法模块完全不碰磁盘,所以能用纯函数的方式做单元测试;全项目只有 reader
和 writer 里出现 open(),"不读写命令行之外的文件"这条约束在代码结构上就看得见。
2.2 函数之间的关系
main(argv)
├─ 参数个数 != 3 ──────────────► 打印用法 → 返回 EXIT_USAGE_ERROR (2)
│
├─ compute_similarity(原文, 抄袭版)
│ ├─ reader.read_text(原文) ──► strip_html 剥离网页样板
│ │ └─ 编码识别 → 去 BOM → NFKC
│ ├─ reader.read_text(抄袭版)
│ ├─ normalize(...) 两段 ─────► 去标点空白、字母小写、中文主导时去英文噪声
│ ├─ 有效字符为空 ────────────► EmptyTextError
│ └─ ngram_similarity(规范化的两段)
│ ├─ _select_orders(长度) ──► 超长文本自动降阶
│ └─ 逐阶:count_ngrams ×2 → cosine_similarity
│
├─ 得到一个 0~1 的浮点数
├─ writer.write_answer(答案文件, score)
│ └─ format_score ──► 保留两位小数
└─ 返回 EXIT_OK (0)
任何 PaperCheckError / OSError ──► 打印提示 → 返回 EXIT_RUNTIME_ERROR (1)
一句话概括:main 只负责"参数校验 + 串流程 + 异常转退出码",真正的计算全在 plagiarism
包里,compute_similarity 是唯一对外的计算入口。
2.3 算法关键
核心思路:把文本转成"字符 n-gram 频次向量",用余弦相似度衡量两个向量的夹角。
具体步骤:
-
读入两段文本,做编码识别与网页噪声剥离;
-
规范化:去掉标点、空白、换行,字母转小写,全角转半角;
-
把规范化后的字符流切成相邻 n 个字符的片段(n-gram),用
Counter统计频次; -
对每一阶 n,算两个频次向量的余弦:设片段 g 在 A、B 中出现次数为 A(g)、B(g),则
dot = Σ_g A(g) × B(g) cos = dot / ( |A| × |B| ) |A| = √(Σ_g A(g)²) -
把 1~4 阶的余弦按权重加权平均,得到最终相似度。
| n(片段长度) | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| 权重 | 0.30 | 0.30 | 0.25 | 0.15 |
为什么是"多重集"而不是"集合":aaaa 里的 aa 出现 3 次,频次要参与计算,
否则重复段落的权重会被抹平。这一点有专门的单元测试(test_keeps_multiplicity)。
权重为什么低阶占主导:短片段对局部改写(同义替换、逐字插入、词级乱序)鲁棒,长片段
对无关文本区分度更高,两者互补。这组 0.30/0.30/0.25/0.15 不是拍脑袋定的,是在老师下发的
官方样例上实测调出来的(见 2.4 第 7 条)。
2.4 独到之处
-
和顺序无关。n-gram 频次是全文统计量,所以"把段落顺序打乱"不会让相似度塌掉——
这正是作业要求里"能处理段落顺序发生变化的相似内容"。有对应用例
test_paragraph_reordering_keeps_score_high守着这条性质。 -
零第三方依赖。没有用 jieba 之类的分词库,只用标准库。这样评测机不用联网装包,
pip install -r requirements.txt立刻成功,也不会踩"尝试连接网络"这条红线。 -
中文主导时去英文噪声。官方样例里
del/dis系列是从网页抓下来的,正文外面
裹着 GitHub 页面的英文导航(skip to content / sign up / why github)。这些英文样板
混进正文会把相似度压低 0.2 以上。做法是:当文本以汉字为主(占比 ≥50%)时丢弃非中文
字符;英文论文(无汉字)不受影响。 -
三种编码 + BOM + NFKC。依次尝试 UTF-8 / GB18030 / UTF-16,去掉开头的 BOM,
再做 Unicode NFKC 归一化(全角BC123→ 半角BC123)。 -
HTML 噪声剥离。除了
<script>/<style>,还要处理网页抓取版里那种超长的
<link integrity="sha384-…">标签(这种标签超过 200 字符,用"限制长度的正则"会漏掉,
必须写成无长度上限的匹配)。 -
超长文本自动降阶。超过 80 万字符不再统计 4-gram,超过 200 万字符只保留 2、3 阶,
用可控的精度损失换取内存与时间的安全边界。 -
权重在真实样例上校准。用官方样例跑出"增/删 ≈ 0.80、段落乱序 ≈ 0.95、重度乱序
0.64、无关文本 0.09",而不是凭直觉设参数。调参前后的对比见第六章。 -
快慢两版实现互相验证。
normalize是优化后的查表实现,normalize_reference是
朴素的逐字符实现,用差分测试逐字符比对,防止"优化把语义改坏了"。
三、计算模块接口部分的性能改进
3.1 性能分析图
用 cProfile 跑一遍,再用 pstats 把热点排出来:
python tools/perf_analysis.py

3.2 消耗最大的函数
大文本(55 万字符)下的 cProfile 结果,按自身耗时(tottime)降序:
| 函数 | 调用次数 | tottime | 占比 |
|---|---|---|---|
Counter._count_elements(统计词频) |
8 | 0.728 s | 35.0% |
<genexpr>(切 n-gram 片段) |
3 074 115 | 0.503 s | 24.2% |
str.join |
2 | 0.178 s | 8.5% |
unicodedata.normalize(NFKC) |
2 | 0.153 s | 7.4% |
<genexpr>(余弦的点积) |
1 024 709 | 0.152 s | 7.3% |
builtins.sum |
12 | 0.119 s | 5.7% |
_translation_for(建翻译表) |
2 | 0.051 s | 2.5% |
str.translate(规范化) |
2 | 0.044 s | 2.1% |
结论:消耗最大的是 Counter._count_elements(35%)和切片生成器(24%),
瓶颈在特征统计环节,不在余弦计算,也不在文件读写。
我原本以为瓶颈会在余弦的点积上,实际测出来完全不是——这就是先用剖析工具、再动手优化的
意义。
3.3 改进思路
| 优化点 | 改进前 | 改进后 | 效果 |
|---|---|---|---|
| 文本规范化 | 逐字符调用 Python 函数判断保留与否 | 建「字符 → 目标字符」映射表 + str.translate,映射在 C 层完成 |
60 万字符 0.318 s → 0.174 s(1.8×) |
| 一阶 n-gram | 走通用的切片生成器 | 直接 Counter(text) 快速路径,跳过切片 |
少一次全量生成器 |
| 超长文本 | 一律统计 1~4 阶 | 超 80 万字符去掉 4 阶,超 200 万字符只留 2、3 阶 | 内存可控 |
| 余弦点积 | 遍历两个词表的并集 | 只遍历元素较少的一侧,模长在循环内累加 | 额外空间 O(1) |
改进后重新剖析,热点已经从"规范化"转移到"n-gram 频次统计"(Counter 占 35%),
说明这一步改对了方向。

一个被否决的方案(也值得记下来):我一度认为"计算期间关闭分代 GC"能大幅提速。
第一次测出 2.2×,差点写进报告。后来改成交替测量 A/B 才发现——那 2.2× 是机器负载漂移,
不是算法差异。重新做对照实验(两种写法交替运行)后差距只有 1.06×,落在噪声范围内,
于是这个方案被否决,没有进入最终代码。
这件事让我把测量方法改成:对比实验一律交替 A/B,规模表同时给最快/中位数/最慢。
3.4 改进耗时
性能改进一共花了约 50 分钟:
- 20 分钟用 cProfile 定位瓶颈(先排除了余弦和文件读写);
- 15 分钟把规范化改成"映射表 +
str.translate",把一阶 n-gram 走快速路径; - 10 分钟重新测量、确认分数没有变化(防止"优化改坏了语义");
- 5 分钟做那个被否决的 GC 对照实验(虽然方案没采用,但避免了把噪声当成果)。
3.5 各规模下的时间与内存
| 规模档位 | 原文(字符) | 抄袭版(字符) | 最快 | 中位数 | 最慢 | 峰值内存 |
|---|---|---|---|---|---|---|
| 样例级 | 5 541 | 4 724 | 10.3 ms | 11.4 ms | 25.7 ms | 0.4 MiB |
| 中篇 | 55 386 | 47 074 | 95.6 ms | 96.7 ms | 133.0 ms | 3.9 MiB |
| 长篇 | 184 616 | 156 831 | 366.8 ms | 374.7 ms | 387.0 ms | 13.2 MiB |
| 极端 | 553 847 | 470 860 | 1 067.6 ms | 1 128.4 ms | 1 181.7 ms | 39.4 MiB |
即便 55 万字符的极端输入(约 1.1 MB 文本,远大于课程样例),也在 1.2 秒内、40 MiB
以内完成,距离 5 秒 / 2048 MB 的限制有很大余量。运行时间与文本长度基本呈线性关系。
四、计算模块部分单元测试展示
4.1 测试文件
用 Python 标准库 unittest 编写,放在 3224004083/tests/,共 73 个用例:
| 测试文件 | 覆盖对象 | 用例数 |
|---|---|---|
tests/test_similarity.py |
相似度算法:count_ngrams / cosine_similarity / ngram_similarity / explain |
24 |
tests/test_reader.py |
规范化与读取:normalize / normalize_reference / read_text / strip_html |
15 |
tests/test_cli.py |
命令行契约:main 退出码、write_answer / format_score、参数个数 |
18 |
tests/test_edge_cases.py |
边界与异常:降阶、编码失败、OSError 包装、子进程入口 |
16 |
| 合计 | 73 |
作业要求"至少 10 个测试用例",这里做了 73 个。
4.2 测试代码
① 可手算的数值(不是只验证"落在 0~1 之间"):
def test_hand_computed_multiplicity_case(self) -> None:
# 两个向量的分量可以完全手算出来:
# A = "甲乙甲乙" -> {"甲乙": 2, "乙甲": 1} |A|² = 4 + 1 = 5
# B = "甲乙乙甲" -> {"甲乙": 1, "乙乙": 1, "乙甲": 1} |B|² = 1+1+1 = 3
# 点积 = 2×1 + 1×1 = 3
# cos = 3 / (√5 × √3) = 3 / √15 ≈ 0.774597
grams_a = count_ngrams("甲乙甲乙", 2)
grams_b = count_ngrams("甲乙乙甲", 2)
self.assertAlmostEqual(cosine_similarity(grams_a, grams_b), 3 / math.sqrt(15), places=12)
② 多重集频次必须保留(不能只记"出现过"):
def test_keeps_multiplicity(self) -> None:
# "aa" 在 "aaaa" 中出现 3 次,必须保留频次而不是只记录"是否出现"。
self.assertEqual(count_ngrams("aaaa", 2), {"aa": 3})
③ 段落乱序仍应是高相似(作业的显式要求):
def test_paragraph_reordering_keeps_score_high(self) -> None:
# 段落顺序变化不应该让相似度塌掉,这是作业要求的显式场景。
first = "第一段描述问题背景与已有工作。"
second = "第二段给出算法设计与实现细节。"
third = "第三段展示实验结果并分析误差来源。"
original = first + second + third
shuffled = third + first + second
self.assertGreater(ngram_similarity(original, shuffled), 0.9)
④ 无关文本必须压得足够低:
def test_fully_unrelated_text_stays_low(self) -> None:
red_mangrove = "红树林生长在热带与亚热带海岸的潮间带上"
software = "代码复用是软件工程中被反复讨论的核心议题之一"
self.assertLess(ngram_similarity(red_mangrove, software), 0.15)
⑤ 答案格式精确到两位小数:
def test_writes_value_with_newline(self) -> None:
path = self.tmp / "ans.txt"
write_answer(str(path), 2.0 / 3.0)
self.assertEqual(path.read_text(encoding="utf-8"), "0.67\n")
⑥ 参数错误时保护已有的答案文件(不能把上一次的结果覆盖成垃圾):
def test_missing_input_file_keeps_existing_answer(self) -> None:
self.answer.write_text("keep\n", encoding="utf-8")
code, message = self._run(
[str(self.original), str(self.tmp / "missing.txt"), str(self.answer)]
)
self.assertEqual(code, EXIT_RUNTIME_ERROR)
self.assertIn("不存在", message)
self.assertEqual(self.answer.read_text(encoding="utf-8"), "keep\n")
⑦ 优化后的快速实现必须和参考实现逐字符一致:
def test_translate_version_matches_reference(self) -> None:
# 查表 + translate 的快速实现必须与逐字符参考实现完全一致,
# 防止"性能优化改坏了语义"。
samples = [
"",
"。。。!?",
"Hello, World! 123",
"ABC123",
"今天是星期天,天气晴。",
"Mixed 中英 text 2026",
"café Ünïcode ∑∆",
]
for sample in samples:
self.assertEqual(normalize(sample), normalize_reference(sample))
⑧ 超长文本的降阶策略(白盒测试私有函数):
def test_large_text_drops_fourth_order(self) -> None:
self.assertEqual(_select_orders(1_000_000), [1, 2, 3])
def test_huge_text_keeps_middle_orders_only(self) -> None:
self.assertEqual(_select_orders(5_000_000), [2, 3])
4.3 测试函数与数据构造
| 用例 | 测的函数 | 构造思路 |
|---|---|---|
test_counts_adjacent_pairs |
count_ngrams |
"abcd" 切 2 阶,期望 3 个片段各 1 次 |
test_keeps_multiplicity |
count_ngrams |
"aaaa" 切 2 阶,验证频次是 3 而不是"出现" |
test_returns_empty_when_text_shorter_than_n |
count_ngrams |
文本比 n 短("ab" 切 4 阶),期望空 |
test_rejects_non_positive_n |
count_ngrams |
边界值 n=0,期望抛 ValueError |
test_hand_computed_multiplicity_case |
cosine_similarity |
分量可手算,期望值 3/√15 |
test_single_dimension_vector_scores_one |
cosine_similarity |
单维度向量,把"余弦对长度不敏感"显式记录下来 |
test_is_symmetric |
cosine_similarity |
交换两个输入,期望结果相同 |
test_identical_text_scores_exactly_one |
ngram_similarity |
完全相同的文本,期望恰好 1.0 |
test_fully_unrelated_text_stays_low |
ngram_similarity |
红树林 vs 软件工程,期望 < 0.15 |
test_paragraph_reordering_keeps_score_high |
ngram_similarity |
三段文字循环移位,期望 > 0.9 |
test_insertion_keeps_score_high |
ngram_similarity |
在原句后追加一句,期望仍在 0.6~1.0 |
test_deletion_keeps_score_high |
ngram_similarity |
删掉后半句,期望仍在 0.5~1.0 |
test_score_always_within_unit_interval |
ngram_similarity |
6 组含空串、单字符的极端输入,验证值域 |
test_explain_reports_every_order |
explain |
验证逐阶明细的键齐全且与总分一致 |
test_removes_whitespace_and_punctuation |
normalize |
带全角标点与换行的句子 |
test_translate_version_matches_reference |
normalize / normalize_reference |
差分测试:6 组含全角、重音、空串的样本逐字符比对 |
test_reads_gb18030_file |
read_text |
等价类:用 GB18030 编码写临时文件 |
test_bom_is_removed_even_without_utf8_sig |
read_text |
桩替换编码列表,验证 BOM 被去掉 |
test_removes_tags_and_scripts |
strip_html |
含 <script> 的网页片段 |
test_numeric_html_entities_are_decoded |
strip_html |
十进制与十六进制实体 |
test_keeps_two_decimal_places |
format_score |
0.8 → "0.80";2/3 → "0.67" |
test_rounds_half_up |
format_score |
边界值 0.005 / 0.0049 |
test_clamps_to_unit_interval |
format_score |
越界值 1.4 / -0.2 |
test_writes_value_with_newline |
write_answer |
临时文件写入后读回比对 |
test_rejects_directory_as_result |
write_answer |
把目录当答案路径 |
test_argument_count_zero/one/two/four |
main |
边界值:参数 0 / 1 / 2 / 4 个,期望退出码 2 |
test_missing_input_file_keeps_existing_answer |
main |
文件不存在 + 预置答案内容,验证答案没被破坏 |
test_directory_as_input_returns_error |
main |
把目录当输入文件 |
test_empty_copied_file_returns_error |
main |
空文件,期望退出码 1 |
test_punctuation_only_file_returns_error |
main |
纯标点文件,规范化后为空 |
test_answer_not_created_when_input_missing |
main |
验证失败时不会创建半成品答案文件 |
test_large_text_drops_fourth_order |
_select_orders |
白盒测试降阶阈值 100 万字符 |
test_huge_text_keeps_middle_orders_only |
_select_orders |
500 万字符只留 2、3 阶 |
test_open_failure_is_wrapped |
read_text / write_answer |
mock 掉 open 抛 OSError,模拟磁盘故障 |
test_unrecognized_encoding_raises |
read_text |
桩替换编码列表为 ascii,使三种编码全失败 |
test_entry_point_script_reports_usage |
main.py 进程入口 |
子进程启动,验证退出码与用法提示 |
测试数据全部用 tempfile.TemporaryDirectory() 动态生成,不依赖任何外部文件,
所以用例可重复、可移植,换台机器照样跑。
4.4 运行方式
# 方式一:标准库 unittest(零依赖)
python -m unittest discover -s tests -t .
# 方式二:pytest(需要 pip install -r requirements-dev.txt)
python -m pytest tests -q
用例写成 unittest 风格,好处是两种跑法都支持——评测机没装 pytest 也能直接跑。
4.5 覆盖率
用 coverage 统计:
python tools/test_coverage.py
输出:
Name Stmts Miss Cover Missing
--------------------------------------------------------
main.py 31 0 100%
plagiarism\__init__.py 6 0 100%
plagiarism\errors.py 5 0 100%
plagiarism\normalize.py 40 0 100%
plagiarism\reader.py 53 0 100%
plagiarism\similarity.py 73 0 100%
plagiarism\writer.py 24 0 100%
--------------------------------------------------------
TOTAL 232 0 100%
用例总数:73 失败:0 错误:0
总体语句覆盖率:100.0%

73 个用例全部通过,232 条语句 0 条未覆盖,语句覆盖率 100%。其中
if __name__ == "__main__" 这类进程入口按惯例用 # pragma: no cover 排除,改由
"以子进程方式启动 main.py"的用例实际验证退出码,所以没有留下未验证的路径。
五、计算模块部分异常处理说明
评测环境把"异常退出"视为失败,所以程序内部所有可预期的错误都转成自定义异常,
由 main 统一捕获、打印提示、以非零退出码结束,绝不把 Python 堆栈抛给评测机。
退出码约定:0 正常 / 1 运行期错误 / 2 参数个数错误。
| 异常 | 设计目标 | 单元测试 | 场景 |
|---|---|---|---|
| 参数个数错误 | 阻止参数越界访问,避免 IndexError,并提示正确用法 |
test_argument_count_zero/one/two/four |
只给 0~2 个参数,或给了 4 个以上 |
FileReadError(不存在) |
输入不可用时明确提示,并保护已有答案不被覆盖 | test_missing_input_file_keeps_existing_answer |
路径拼错、把目录当文件传、传了空字符串 |
FileReadError(编码) |
不把乱码当正文参与计算;三种编码都失败时给出明确提示 | test_unrecognized_encoding_raises |
文件既不是 UTF-8,也不是 GB18030 / UTF-16 |
FileReadError(打开失败) |
把底层 OSError 包装成统一异常,不让原始堆栈外泄 |
test_open_failure_is_wrapped |
权限不足、设备不可用 |
AnswerWriteError |
写不进去就明确报错,不能"看起来成功了" | test_open_failure_is_wrapped(writer 版)、test_rejects_directory_as_result |
答案路径是目录、上级目录不存在、磁盘只读 |
EmptyTextError |
没有可比较内容时不给出一个没有意义的分数,而要明确报错 | test_punctuation_only_file_returns_error、test_both_files_empty_raises |
文件是空的,或者全是标点(规范化之后什么都不剩) |
兜底 OSError |
没预料到的系统级错误也不能崩溃 | test_unexpected_oserror_returns_runtime_error |
磁盘故障等运行时才出现的错误 |
5.1 代码示例
异常体系(plagiarism/errors.py):
class PaperCheckError(Exception):
"""本项目所有可预期错误的基类。"""
class FileReadError(PaperCheckError):
"""输入文件不可用:不存在、是目录、无权限或编码无法识别。"""
class EmptyTextError(PaperCheckError):
"""输入文件存在,但内容里没有任何可供比较的有效字符。"""
class AnswerWriteError(PaperCheckError):
"""答案文件无法写入:路径是目录、上级目录不存在、磁盘不可写等。"""
读取时把底层错误统一包装(plagiarism/reader.py 节选):
def read_text(path: str) -> str:
if not isinstance(path, str) or not path:
raise FileReadError("输入文件路径为空")
if os.path.isdir(path):
raise FileReadError(f"输入路径是目录而不是文件: {path}")
if not os.path.exists(path):
raise FileReadError(f"输入文件不存在: {path}")
try:
with open(path, "rb") as handle:
raw = handle.read()
except OSError as exc:
raise FileReadError(f"无法读取输入文件 {path}: {exc}") from exc
for encoding in _ENCODINGS:
try:
text = raw.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
break
else:
raise FileReadError(f"无法识别的文件编码(已尝试 UTF-8/GB18030/UTF-16): {path}")
if text.startswith("\ufeff"):
text = text[1:]
text = text.replace("\x00", "")
text = unicodedata.normalize("NFKC", text)
text = text.replace("\r\n", "\n").replace("\r", "\n")
return strip_html(text)
先把文件读成字节,再逐个编码尝试解码——这样即使第一次解码失败,也不用重新读盘。
5.2 单元测试
① 文件不存在 → FileReadError,且不破坏已有答案
def test_missing_input_file_keeps_existing_answer(self) -> None:
self.answer.write_text("keep\n", encoding="utf-8")
code, message = self._run(
[str(self.original), str(self.tmp / "missing.txt"), str(self.answer)]
)
self.assertEqual(code, EXIT_RUNTIME_ERROR)
self.assertIn("不存在", message)
self.assertEqual(self.answer.read_text(encoding="utf-8"), "keep\n")
路径不存在时抛 FileReadError,在 main 里被捕获,打印提示后以退出码 1 结束;
答案文件保持原样,不会被覆盖成垃圾。
② 编码无法识别 → FileReadError
def test_unrecognized_encoding_raises(self) -> None:
path = self.tmp / "cn.txt"
path.write_text("论文查重程序", encoding="utf-8")
with mock.patch("plagiarism.reader._ENCODINGS", ("ascii",)):
with self.assertRaises(FileReadError) as ctx:
read_text(str(path))
self.assertIn("编码", str(ctx.exception))
用 mock 把编码列表替换成只有 ascii,模拟"三种编码全部失败"。
③ 打开失败 → 包装成 FileReadError / AnswerWriteError
def test_open_failure_is_wrapped(self) -> None:
path = self.tmp / "x.txt"
path.write_text("内容", encoding="utf-8")
with mock.patch("builtins.open", side_effect=OSError("设备不可用")):
with self.assertRaises(FileReadError) as ctx:
read_text(str(path))
self.assertIn("无法读取", str(ctx.exception))
④ 空文本 / 纯标点 → EmptyTextError
def test_punctuation_only_file_returns_error(self) -> None:
punct = self.tmp / "punct.txt"
punct.write_text("。。。!!!", encoding="utf-8")
code, _ = self._run([str(self.original), str(punct), str(self.answer)])
self.assertEqual(code, EXIT_RUNTIME_ERROR)
⑤ 参数个数错误 → 退出码 2
def test_argument_count_four(self) -> None:
code, _ = self._run([str(self.original), str(self.copied), str(self.answer), "extra"])
self.assertEqual(code, EXIT_USAGE_ERROR)
⑥ 兜底:无法归类的系统错误
def test_unexpected_oserror_returns_runtime_error(self) -> None:
buffer = io.StringIO()
with mock.patch("main.compute_similarity", side_effect=OSError("磁盘故障")):
with contextlib.redirect_stderr(buffer):
code = cli.main(["a.txt", "b.txt", "c.txt"])
self.assertEqual(code, cli.EXIT_RUNTIME_ERROR)
self.assertIn("磁盘故障", buffer.getvalue())
还有一条容易忽略的边界:答案文件不能指向输入文件,否则一次误操作就会把原文覆盖掉。
当前实现通过"任何失败都保持答案文件原样"来规避(见测试 ①)。
六、实际运行结果
用老师下发的官方样例(测试文本.zip)跑,命令行方式与作业要求一致:
python main.py sample/official/orig.txt sample/official/orig_0.8_add.txt ans.txt
批量跑完整目录:
python tools/batch_check.py sample/official/orig.txt sample/official/
官方样例的重复率:
| 样例 | 改动类型 | 相似度 |
|---|---|---|
orig_0.8_add.txt |
逐字插入(增) | 0.7951 |
orig_0.8_del.txt |
删除约 20%(删) | 0.7976 |
orig_0.8_dis_1.txt |
乱序 · 轻 | 0.9492 |
orig_0.8_dis_10.txt |
乱序 · 中 | 0.8355 |
orig_0.8_dis_15.txt |
乱序 · 重(词级) | 0.6373 |
add / del 恰好落在 0.80 附近,与文件名里的 "0.8" 一致;乱序程度递增时分数单调
下降,重度乱序仍能给出 0.64 的明确"抄袭"信号,而不会被误判成无关文本。
自造样例的重复率(python tools/run_samples.py):
| 样例 | 说明 | 相似度 |
|---|---|---|
orig_1.0.txt |
与原文完全一致 | 1.0000 |
orig_1.0_html.txt |
正文相同但夹带 HTML 样板 | 0.9955 |
orig_0.8_syn.txt |
术语替换为近义词(改) | 0.9588 |
orig_0.8_dis_1.txt |
段内句子局部旋转(乱序) | 0.9860 |
orig_0.8_dis_2.txt |
全部句子整体打乱(乱序) | 0.9640 |
orig_0.8_dis_3.txt |
句子内部分句顺序颠倒(乱序) | 0.9501 |
orig_0.8_add.txt |
插入约 20% 新句子(增) | 0.9355 |
orig_0.8_del.txt |
删除约 20% 句子(删) | 0.9215 |
orig_0.8_del_gbk.txt |
同上,但文件是 GB18030 编码 | 0.9215 |
orig_0.0.txt |
完全不同主题 | 0.1066 |
orig_empty.txt |
空文件 | 报错退出,不给分数 |
orig_punct_only.txt |
纯标点 | 报错退出,不给分数 |
评测约束自查:
| 约束 | 实测 |
|---|---|
| 5 秒内给出答案 | 官方样例约 20 ms;55 万字符极端输入 1.13 s |
| 内存不超过 2048 MB | 极端输入峰值 39.4 MiB |
| 不连接网络 | 零第三方依赖,全项目只 import 标准库 |
| 不读写其他文件 | 全项目只有 reader / writer 两处 open(),只碰命令行给出的三个路径 |
| 不异常退出 | 所有可预期错误都转成退出码 1 / 2 |
七、代码质量分析
作业要求"代码经过 Code Quality Analysis 工具分析并消除所有的警告"。Python 生态里
承担这个角色的是 pylint(按 PEP 8 与常见缺陷模式打分,满分 10.00)。
python tools/code_quality.py
# 或
python -m pylint main.py plagiarism tests tools
结果:10.00 / 10,0 条警告。
做法是先修代码,再谈配置:
- 补全缺失的 docstring、拆分过长的函数、消除重复的绘图样板、修正过长的行、
清理未使用的变量与导入、把裸Exception换成具体异常; - 抽出
tools/plotting.py统一处理 matplotlib 的导入与中文字体,消除两个脚本间的
重复代码; .pylintrc里只保留两处有理由的局部豁免:测试函数名已完整表达断言意图,
不重复写 docstring;unittest的setUp/tearDown用不了with。
八、签入记录
Github 仓库:https://github.com/xxyyll0323/3224004083
按"每完成一个功能、编译通过就提交一次"的要求管理代码,提交按功能阶段划分:
| 提交 | 说明 |
|---|---|
feat: 基本功能 —— 命令行论文查重 |
最小可用版本:三参数、读写文件、二元组相似度、两位小数 |
feat: 扩展功能 —— 多阶加权、多编码识别、噪声剥离与降阶保护 |
补齐 1~4 阶加权、UTF-8/GB18030/UTF-16、HTML 剥离、超长降阶 |
perf: 规范化改用查表 + str.translate,提速约 3 倍 |
用 cProfile 定位瓶颈后做的性能改进 |
feat: 批量查重工具(扩展功能) |
tools/batch_check.py,一次算完整个样例目录并导出 CSV |
test: 72 个单元测试 + 自造样例集 + 覆盖率报告 |
四类用例 + 12 份自造样例 + 覆盖率图 |
refactor: 相似度度量由 Dice 统一为余弦相似度,并补充选型对照实验 |
换度量,附 Dice / Jaccard / 余弦的对照数据 |
chore: 引入 pylint 做代码质量分析,把所有警告清零(10.00/10) |
补 docstring、拆函数、消重复,警告从 149 降到 0 |
docs: 博客随笔、PSP 表格、使用指南与项目说明 |
交付文档 |
docs: 恢复被取代的两份旧文档到 docs/archive/ |
留档,避免误以为删除 |
fix: 用老师下发的官方样例跑通并调优算法(增/删 ≈ 0.80) |
按真实样例调规范化与权重 |
chore: 公开仓库前清理,移除老师下发的测试文本与《活着》全文 |
官方样例含出版小说全文,不放进公开仓库 |
docs: 博客随笔整理成可直接上交的最终版 |
PSP 实际耗时回填、图片链接整理 |
docs: 补强博客第五、六章(单元测试 12' 与异常处理 6') |
补被测函数清单、测试数据构造手法、覆盖率数字纠错 |
docs: 博客按参考范例重写为九章结构,PSP 表格回填实际耗时 |
按评分细则重排章节,并核对全部引用的测试用例名与代码 |
可以看出来,提交不是一次性堆上去的:基本功能 → 扩展功能 → 性能 → 测试 → 代码质量
→ 文档 → 按真实样例调优,每一步都能在仓库里找到对应记录。最新的完整提交记录见仓库
的提交历史。
九、总结
这次的收获:
- 算法选型要用数据说话。同一个 n-gram 特征,换成 Dice / Jaccard / 余弦会差多少?
我把三种度量放在同一批样例上跑了对照(差异都在 0.03 以内),才决定用余弦。
权重也是在官方样例上实测调出来的,不是凭感觉。 - 不要凭直觉优化。我原以为瓶颈在余弦的点积上,cProfile 测出来却是"文本规范化"和
"n-gram 频次统计"。改成查表 +str.translate后规范化提速 1.8×。 - 测量方法本身也会出错。那个"关闭分代 GC 提速 2.2×"的结论是假的——先测完 A 再测 B,
把机器负载漂移当成了算法差异。改成交替测量后差距只有 1.06×,方案被否决。 - 覆盖率补到 100% 靠的是覆盖分支,不是堆用例。为了补上"进程入口"和"编码全部失败"
这两条路径,专门加了子进程启动测试和 mock 替换编码列表的测试。
不足与改进计划:
- 相似度是字面相似度,不理解语义,所以"星期天 ↔ 周天"这类同义改写只能靠字符重合
来识别(实测 0.94,靠的是"天""晴"这些字)。要真正解决得上语义向量,但那会引入第三方
模型,违反"不联网 + 零依赖"的约束。 - 权重只在官方那一份样例上调过。更严谨的做法是构造几十对无关论文,看分数分布的上界,
用它来定阈值。 - 没有标注数据集,因此给出的分数是相对指标,不能直接当成"抄袭比例"来解读。



浙公网安备 33010602011771号