第一次个人编程作业
软件工程第一次作业:论文查重程序(个人项目)
| 这个作业属于哪个课程 | 软件工程 |
|---|---|
| 这个作业要求在哪里 | 个人项目 |
| 这个作业的目标 | 了解软件设计开发测试全流程,跟着熟练学习 |
作业 Github 仓库链接:https://github.com/ttr-rui/TTR-rui
(学号文件夹:3224004344/)
-
语言与运行环境:Python 3.13,Windows 10 64-bit
-
算法:汉字 2-gram 特征 + 余弦相似度(零第三方依赖)
-
运行方式:
python main.py [原文文件] [抄袭版论文的文件] [答案文件]
一、PSP 表格(预估)
下表在动手编码之前填写,用于估算各阶段的工作量。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 30min |
| · Estimate | · 估计这个任务需要多少时间 | 30min |
| Development | 开发 | 600min |
| · Analysis | · 需求分析(包括学习新技术) | 90min |
| · Design Spec | · 生成设计文档 | 45min |
| · Design Review | · 设计复审 | 30min |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 20min |
| · Design | · 具体设计 | 45min |
| · Coding | · 具体编码 | 210min |
| · Code Review | · 代码复审 | 40min |
| · Test | · 测试(自我测试,修改代码,提交修改) | 120min |
| Reporting | 报告 | 120min |
| · Test Report | · 测试报告 | 45min |
| · Size Measurement | · 计算工作量 | 15min |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 60min |
| 合计 | 750min |
预估时的基本判断:这是一个输入输出都很明确的单人程序,算法不复杂,主要工作量在「写完之后的测试与文档」上。
事后看,这个判断本身没错,但每一步的耗时都被低估了。
二、需求分析
2.1 功能需求
给定一篇原文和一篇在其基础上经过增删改的抄袭版论文,计算二者的重复率并写入答案文件。程序需完成四件事:
- 从命令行接收 3 个参数:原文路径、抄袭版路径、答案路径
- 从指定位置读取这两份文本
- 计算重复率(0 ~ 1 的浮点数)
- 把结果写入答案文件,精确到小数点后两位
2.2 输入输出规格
| 项目 | 规格 |
|---|---|
| 输入方式 | 命令行参数,3 个,以空格分隔 |
| 输入格式 | 纯文本文件,路径为绝对路径,路径中不含空格 |
| 输出方式 | 写入指定的答案文件 |
| 输出格式 | 浮点数,保留两位小数(例如 0.61) |
根据作业要求的约束条件直接推出了两条硬性设计原则:
- 算法必须足够快、足够省内存 → 决定了算法选型
- 任何情况下都不能以异常堆栈崩溃退出 → 决定了异常处理架构
三、计算模块接口的设计与实现过程
3.1 代码组织:6 个文件、8 个函数、4 个异常类
程序按职责拆分为 1 个入口文件 + 5 个功能模块:
| 文件 | 职责 | 对外提供 |
|---|---|---|
main.py |
入口骨架:解析参数、调度、写出结果 | parse_args(argv) / main() |
errors.py |
自定义异常体系 | PlagiarismError / ParameterError / FileReadError / FileWriteError |
file_reader.py |
文件读取与多编码兼容 | read_text(path) |
text_processor.py |
汉字 2-gram 特征切分与频次统计 | tokenize(text) / build_word_freq(tokens) |
similarity.py |
余弦相似度与查重编排 | cosine_similarity() / calc_similarity() |
answer_writer.py |
答案文件写出与格式化 | write_answer(path, similarity) |
模块之间的依赖是单向的,不存在循环导入:
为什么要拆成多个文件,而不是写在一个 main.py 里?
一开始我确实是按单文件写的(因为程序小、单文件更利于评测时直接运行),但后来发现两个问题:一是所有功能挤在一起,「哪个函数属于哪一层」全靠读代码猜;二是单元测试时要测一个纯函数,却不得不把整个程序连同文件 IO 一起测。
拆分后:每个模块只暴露一到两个函数,接口明确,便于逐个编写测试;main.py 只做「拿参数 → 调度 → 写结果」三件事,64 行,一眼看完; 上层依赖下层,下层不反向依赖;异常类型集中在 errors.py 供各层共用;性能分析定位到瓶颈模块后,可以只改动单个文件。
⚠️ 代价是
main.py不再自包含,运行时六个文件必须位于同一目录
(Python 会把脚本所在目录加入模块搜索路径),提交时不可缺漏。
3.2 分层设计
| 层 | 承担者 | 职责 |
|---|---|---|
| 入口层 | main.py 的 main() / parse_args() |
只负责「拿参数、调编排层、写文件」,不关心算法细节 |
| 编排层 | similarity.py 的 calc_similarity() |
决定「先做什么、后做什么」,不关心每一步怎么实现 |
| 计算层 | read_text / tokenize / build_word_freq / cosine_similarity / write_answer |
每个函数是一段纯粹的、可独立测试的计算逻辑 |
| 公共层 | errors.py |
仅定义异常类型,被各层引用,不依赖任何其它模块 |
分层带来的直接好处体现在测试上:tokenize()、build_word_freq()、
cosine_similarity() 都是纯函数,不需要准备任何文件就能构造输入,
所以 42 个测试用例全部跑完只要 0.15 秒。
3.3 关键函数流程图
主流程:
核心函数 cosine_similarity() 的内部逻辑(这是性能最敏感的一段):
3.4 算法的关键
为什么选余弦相似度:
| 候选算法 | 不采用的原因 |
|---|---|
| 编辑距离(Levenshtein) | 时间复杂度 O(n×m),长文本上远超 5 秒限制 |
| SimHash 指纹 | 适合海量文档去重,但不能直接给出 0~1 的重复率 |
| 最长公共子串 | 只能捕捉连续复制,对「增删改」不敏感 |
| 整词词袋 + 余弦 | 只统计词的出现次数、完全忽略词序,识别不了「打乱语序」 |
| 汉字 2-gram + 余弦 | 特征携带局部字序信息,能同时捕捉增、删、改与乱序,天然输出 0~1 |
特征粒度为什么选「2-gram」,而不是「整词」或「单字」 —— 这是本次作业
做得最实质的一次取舍。用实测数据说话(同一批测试文本,只是把特征粒度换掉):
| 特征粒度 | add | del | dis_1 | dis_10 | dis_15 | 原文特征数 |
|---|---|---|---|---|---|---|
| 单字(1-gram) | 0.998 | 0.998 | 1.000 | 1.000 | 1.000 | 1 173 |
| 汉字 2-gram | 0.897 | 0.899 | 0.974 | 0.905 | 0.751 | 5 557 |
| 汉字 3-gram | 0.627 | 0.644 | 0.917 | 0.708 | 0.345 | 7 761 |
- 整词(jieba 分词):需要加载 40 MB 量级的词典,耗时与内存都被第三方库
主导;更致命的是词袋模型丢掉词序 —— 实测把「相邻两字互换」的抄袭版与原文
对比,重复率算出0.98 ~ 1.00,与肉眼判断完全不符。 - 单字:完全没有顺序信息。上表第一行就是证据 —— 三个乱序抄袭版全部得到
1.000,等于把「乱序」当成了「原文」。 - 3-gram:顺序信息过多,只做了增删字的
add/del被压到0.63 ~ 0.64,
与「大部分内容还保留着」的事实不符,区分度反而失真。 - 2-gram:既不依赖任何词典,又保留了「谁挨着谁」这一最基础的顺序信息,是精度与开销的平衡点。
算法步骤:清洗 → 切 2-gram 特征 → 向量化 → 计算。
- 清洗:用正则
[\W_]+一次性剔除标点、空白等噪声字符,只保留汉字与字母数字 - 切特征:对清洗后的字符序列滑动取窗,每相邻两个字符构成一个特征
- 向量化:把每篇文章表示成「特征 → 出现次数」的稀疏向量
- 计算:求两个向量的余弦相似度,得到 0~1 之间的重复率
数学原理:设两篇文章切分特征后的特征频次向量为 a 和 b,则
A · B Σ (aᵢ × bᵢ)
cos(θ) = ───────────── = ─────────────────────────
|A| × |B| √(Σaᵢ²) × √(Σbᵢ²)
- 分子
A · B是内积,只对两篇文章共有的特征求和——共有的特征越多、
出现得越频繁,分子越大 - 分母是两向量模长的乘积,用于归一化,消除文章长度的影响:
一篇文章是另一篇的两倍长,只要用词比例相同,相似度依然是 1 - 结果落在
[0, 1]:越接近 1 越相似,0 表示毫无重合
用题面样例验证:
原文: 今天是星期天,天气晴,今天晚上我要去看电影。
抄袭版:今天是周天,天气晴朗,我晚上要去看电影。
剔除标点后,原文得到 18 个 2-gram 特征(今天 出现两次,其余 16 个各一次),
抄袭版得到 16 个(各一次),两者共有 今天、天是、天天、天气、气晴、晚上、要去、去看、看电、电影
这 10 个特征。代入公式:
cos = (2×1 + 1×1×9) / (√(2² + 1²×16) × √(1²×16))
= 11 / (4.472 × 4) ≈ 0.615 → 0.61
程序实测输出 0.61,与「抄袭版大部分内容照搬、少部分改写」的直觉一致。
四、计算模块接口部分的性能改进
4.1 分析方法与测试数据
- 工具:Python 标准库
cProfile(确定性性能分析器)+pstats(结果统计), 配合matplotlib绘制分析图;内存以进程峰值工作集计量 - 测试数据:把老师下发的
orig.txt重复拼接成一份 380 KB(约 12 万汉字) 的长文本作为原文;抄袭版通过「打乱句子顺序 + 对约 35% 的句子随机删改」生成,模拟「增删改」场景
采集命令:
python -m cProfile -o base.prof main.py orig_big.txt copy_big.txt ans.txt
python -c "import pstats; pstats.Stats('base.prof').sort_stats('tottime').print_stats(15)"
4.2 性能基线
| 场景 | 端到端耗时 | 峰值内存 | 输出 |
|---|---|---|---|
| 短样例(22 字) | 0.29 秒 | 23.1 MB | 0.61 |
| 长文本(380 KB) | 0.36 秒 | 23.1 MB | 0.99 |
题目限制为 5 秒 / 2048 MB,基线已达标,且留有十余倍余量。
测量口径(重要):本文所有「端到端耗时」指命令行
python main.py ...
的墙钟时间,含 Python 解释器启动。在本机上,一个什么都不做的空脚本
启动一次就要约 0.28 秒(实测 7 次中位数),这部分开销与程序代码无关。
扣除启动后,380 KB 长文本的真正计算时间约 0.08 秒。
下面 cProfile 表格中的耗时只统计进程内部的函数执行时间,同样不含启动开销。
4.3 瓶颈定位:消耗最大的函数
改进前,按累计耗时排序(cProfile 采集),瓶颈一眼可见:
| 函数 | 累计耗时 | 占总时间 |
|---|---|---|
text_processor.py: tokenize |
1.988 s | 92.6% |
└ jieba.lcut → jieba.cut |
1.753 s | 81.6% |
└ jieba.__cut_DAG(分词主算法) |
1.618 s | 75.4% |
└ jieba.get_DAG(构词图) |
0.937 s | 43.6% |
└ jieba.initialize(加载词典) |
0.640 s | 29.8% |
└ marshal.load(反序列化词典) |
0.639 s | 29.8% |
tokenize 自身只占 0.133 秒(6.2%),其余全部发生在它调用的 jieba 内部:
词典加载 0.64 秒 + 分词算法 0.94 秒,合计约 79%。也就是说,
耗时和内存都被第三方库主导,项目自身代码只占 8% 左右。
改进后,按函数自身耗时(tottime)排序的前几名
(380 KB 长文本,进程内合计 0.104 秒):
| 函数 | 自身耗时 | 占进程内总耗时 |
|---|---|---|
Counter._count_elements(统计特征频次) |
0.039 s | 37.5% |
tokenize 生成器(切分 2-gram,共 222 587 次) |
0.033 s | 31.7% |
re.Pattern.sub(剔除标点与空白) |
0.008 s | 7.7% |
builtins.sum(内积与模长求和) |
0.004 s | 3.8% |
| 余弦相似度相关的生成器(模长、交集) | 0.005 s | 4.8% |
| 文件 IO 与模块加载等 | 0.015 s | 14.5% |
📌 性能分析图

结论:
- 改进后耗时集中在两处:特征频次统计与 2-gram 特征切分,
两者合计约占 69% —— 它们就是本项目消耗最大的函数。 - 余弦相似度计算仅占 0.011 秒:两篇文章去重后的特征各约 5 500 个,
交集运算规模远小于特征切分。 - 原本占 92.6% 的
jieba分词链路已完全消失:不再加载词典
(原先 0.64 秒、53 MB),也不再执行 HMM 分词(原先 0.94 秒)。
4.4 改进思路与实现
优化:特征粒度由「整词词袋」改为「汉字 2-gram」
这是本次作业最关键的一次优化,它同时解决了性能和准确度两个问题。
- 原实现:用
jieba.cut()精确模式分词,得到「词 → 词频」向量。
剖析显示耗时与内存都被第三方库主导(见 4.3),且词袋模型丢失词序。 - 改进:
# 只保留汉字与字母数字;标点、空白等噪声字符一并剔除
NOISE_PATTERN = re.compile(r"[\W_]+", re.UNICODE)
# N-gram 窗口大小:2 表示「连续两个字符」构成一个特征
NGRAM_SIZE = 2
def tokenize(text):
cleaned = NOISE_PATTERN.sub("", text)
return (
cleaned[i:i + NGRAM_SIZE]
for i in range(len(cleaned) - NGRAM_SIZE + 1)
)
- 清洗用一条正则
[\W_]+在 C 层完成,不写 Python 层循环; - 特征切分用生成器表达式惰性产出,由
Counter直接消费,不落地中间列表; - 不再依赖任何第三方库,
requirements.txt因此变成零依赖。
- 收益:端到端耗时降 82.5%(纯计算降 95.5%)、峰值内存降 72.7%、
第三方依赖归零,同时把「乱序抄袭」的判定从0.98 ~ 1.00修正到0.75 ~ 0.97。
4.5 优化效果
同一台机器、同一份 380 KB 长文本,新旧两版交替各跑 7 次取中位数
(这样两版面对完全相同的机器负载,对比才有意义):
| 版本 | 端到端耗时 | 相对基线 | 峰值内存 | 相对基线 |
|---|---|---|---|---|
| 基线版(整词词袋 + jieba) | 2.06 s | — | 84.6 MB | — |
| 优化版(汉字 2-gram) | 0.36 s | −82.5% | 23.1 MB | −72.7% |
若按「扣掉解释器启动(约 0.28 s)」的纯计算口径比较,则是
1.78 s → 0.08 s,降幅 95.5% —— 端到端数字之所以没这么夸张,
是因为 0.36 秒里有 0.28 秒是启动开销,这部分新版旧版都一样。
📌 性能对比图

5 个测试文本(29 KB 量级)的端到端耗时同步从平均 1.07 秒降到 0.30 秒:
| 测试文本 | 改进前耗时 | 改进后耗时 | 改进前输出 | 改进后输出 |
|---|---|---|---|---|
orig_0.8_add.txt |
1.01 s | 0.28 s | 0.99 | 0.90 |
orig_0.8_del.txt |
1.08 s | 0.29 s | 0.99 | 0.90 |
orig_0.8_dis_1.txt |
1.05 s | 0.29 s | 1.00 | 0.97 |
orig_0.8_dis_10.txt |
1.04 s | 0.28 s | 0.99 | 0.91 |
orig_0.8_dis_15.txt |
1.15 s | 0.34 s | 0.98 | 0.75 |
这里有一处必须说明:优化改变了相似度数值,从
0.98 ~ 1.00变成
0.75 ~ 0.97。这不是「优化破坏了结果」,而恰恰是优化的目的之一 ——
原方案的数值本身就是错的(把明显乱序的文本判为完全重复)。
题目 22 字样例在两种方案下均为0.61,未受影响。
4.6 性能改进所耗费的时间
| 环节 | 耗时 |
|---|---|
工具学习(cProfile / pstats 用法、进程内存采集) |
约 30 分钟 |
| 构造 380 KB 长文本测试数据、采集基线数据 | 约 20 分钟 |
| 特征粒度改进(整词 → 2-gram)的实现与验证 | 约 40 分钟 |
| 方案对比实验(n=1/2/3、HMM 开关)与图表绘制 | 约 45 分钟 |
| 合计 | 约 135 分钟 |
五、计算模块部分单元测试展示
5.1 测试设计思路
采用三类白盒设计方法组合,目标是让每个函数的正常分支与异常分支都被执行到:
| 方法 | 用法 | 举例 |
|---|---|---|
| 等价类划分 | 把输入划成若干等价集合,每类取一个代表 | 文件编码分为 UTF-8 / GBK / 非法字节三类;参数个数分为 3 个(合法)与 0、2、4 个(非法) |
| 边界值分析 | 取区间的端点与临界点 | 相似度取 0.0 与 1.0;参数个数取 3 的左右邻居;空文件、纯空白文件、纯标点文本 |
| 异常路径覆盖 | 主动构造触发 raise 的输入 |
文件不存在、路径指向目录、目标目录不可写、编码全部失败 |
测试数据全部由 tempfile.TemporaryDirectory() 在临时目录中动态生成,
不依赖磁盘上的任何固定文件,因此能在任何机器上直接运行,也不会污染被测目录。
5.2 测试概览
| 项目 | 结果 |
|---|---|
| 测试框架 | unittest(Python 标准库) |
| 测试文件 | test_main.py |
| 用例总数 | 42 个(作业要求 ≥10) |
| 执行结果 | 全部通过(OK) |
| 执行耗时 | 0.15 秒 |
| 被测模块 | 6 个 |
| 语句总数 | 87 条(仅统计业务模块) |
| 语句覆盖率 | 100% |
用例分布:
| 被测模块 | 用例数 | 覆盖内容 |
|---|---|---|
errors.py |
2 | 继承关系、基类捕获子类 |
file_reader.py |
9 | UTF-8/GBK/非法字节、文件缺失、路径是目录、空文件、纯空白、BOM 剥离 |
text_processor.py |
7 | 标点过滤、生成器返回、纯标点文本、频次累计、空输入、2-gram 粒度、短文本边界 |
similarity.py |
11 | 全同/无关/单侧空/双侧空、对称性、值域、缩放不变性、端到端、缺失文件、零向量、顺序敏感 |
answer_writer.py |
4 | 两位小数、边界值 0/1、覆盖截断、不可写路径 |
main.py(参数解析) |
4 | 3 个参数正常、0/2/4 个参数报错 |
main.py(程序入口) |
5 | 成功退出码 0、三类错误退出码 1、脚本方式运行 |
5.3 部分单元测试代码
① 构造编码异常:验证三级回退与兜底解码
思路:直接写入原始字节 b"hello\x80world"。0x80 在 UTF-8、GBK、GB18030
三种编码中都不是合法起始字节,必然触发三级回退,从而覆盖「忽略非法字符强制解码」分支。
def test_09_invalid_bytes_fallback(self):
"""异常路径:三种编码均失败时,忽略非法字符兜底解码。"""
path = self.make_bytes("bad.txt", b"hello\x80world")
self.assertEqual(read_text(path), "helloworld")
② 验证相似度的核心性质:对称性与缩放不变性
思路:cos(a, b) 在数学上必然等于 cos(b, a),且特征频次整体放大不应改变结果。
这两条性质比「具体数值是多少」更能反映实现是否正确。
def test_19_symmetry(self):
"""数学性质:余弦相似度满足对称性 cos(a,b) = cos(b,a)。"""
freq_a = build_word_freq(tokenize(SAMPLE_ORIGINAL))
freq_b = build_word_freq(tokenize(SAMPLE_COPIED))
self.assertAlmostEqual(
cosine_similarity(freq_a, freq_b),
cosine_similarity(freq_b, freq_a),
)
def test_21_scale_invariance(self):
"""数学性质:词频整体放大 k 倍,相似度不变。"""
freq_a = {"a": 1, "b": 2}
scaled = {"a": 3, "b": 6}
self.assertAlmostEqual(
cosine_similarity(freq_a, scaled), 1.0
)
③ 构造除零风险:验证模长为 0 的短路保护
思路:特征字典非空(能通过 if not freq_a 检查),但所有计数为 0,
使得模长 sqrt(0) = 0。若没有短路保护,这里会抛 ZeroDivisionError。
def test_37_zero_vector_is_zero(self):
"""覆盖率补测:向量模长为 0 时直接返回 0,不触发除零。"""
self.assertEqual(cosine_similarity({"词": 0}, {"词": 1}), 0.0)
④ 构造端到端场景:验证退出码与答案文件实际文本
思路:用 mock.patch.object 替换 sys.argv 模拟命令行调用,
用 redirect_stdout 吸收程序输出避免污染测试报告;
断言答案文件的实际文本(而不是浮点值),
这样才能同时验证「保留两位小数」这一格式硬性要求。
def _run(self, argv):
"""在静默标准输出的情况下调用 main(),返回退出码。"""
with mock.patch.object(sys, "argv", argv):
with redirect_stdout(io.StringIO()):
return main.main()
def test_32_success_returns_zero(self):
"""正常路径:退出码 0,答案文件内容为题面样例的重复率。"""
original = self.make_file("orig.txt", SAMPLE_ORIGINAL)
copied = self.make_file("copy.txt", SAMPLE_COPIED)
answer = os.path.join(self.dir, "ans.txt")
code = self._run(["main.py", original, copied, answer])
self.assertEqual(code, 0)
with open(answer, encoding="utf-8") as handle:
self.assertEqual(handle.read(), SAMPLE_SIMILARITY)
⑤ 构造乱序场景:验证特征携带字序信息
思路:取一段语义完整的短句,把每一对相邻的字互换构造出「乱序版」。
如果特征不含字序信息(例如把文本切成单个字后做词袋),这两个字序不同的串
会被判为完全重复;2-gram 特征则因相邻关系被打乱而大量失配。
def test_42_word_order_affects_similarity(self):
"""顺序敏感:把相邻两字互换后,相似度必须明显低于 1。"""
freq_a = build_word_freq(tokenize("天气晴朗我去看电影"))
freq_b = build_word_freq(tokenize("气天朗晴我去看影电"))
value = cosine_similarity(freq_a, freq_b)
self.assertLess(value, 1.0)
self.assertGreater(value, 0.0)
两串各切出 8 个 2-gram 特征,共有 我去、去看 两个,余弦为
2 / (√8 × √8) = 0.25;若把特征粒度退回单字袋,这两个串的字频完全相同,
相似度会变成 1.00。这个用例是防止算法退化的护栏。
5.4 测试覆盖率
在 3224004344 目录下执行:
py -3.13 -m coverage run --source=. --omit=test_main.py -m unittest test_main
py -3.13 -m coverage report -m
py -3.13 -m coverage html -d htmlcov --omit=test_main.py
--omit=test_main.py用于排除测试文件自身,避免测试代码拉低统计口径。
覆盖率报告原文:
Name Stmts Miss Cover Missing
-------------------------------------------------
answer_writer.py 9 0 100%
errors.py 4 0 100%
file_reader.py 26 0 100%
main.py 22 0 100%
similarity.py 17 0 100%
text_processor.py 9 0 100%
-------------------------------------------------
TOTAL 87 0 100%
📌 覆盖率图

5.5 测试评价:这组用例够用吗?
结论:够用,并且做到了语句级全覆盖。
- 42 个用例覆盖了 6 个模块的全部可执行语句,语句覆盖率 100%,无 Missing 行
- 关键风险点全部被主动验证:除零、空输入、非法编码、参数个数错误、
路径不可写、退出码语义——这些正是评测中会导致「异常退出扣 2 分」的场景 - 断言层面不仅检查「不抛异常」,还检查返回值、退出码和文件实际文本,
因此「保留两位小数」这类格式要求也在测试保护范围内
已知局限(诚实说明):
- 用例 36 通过
unittest.mock注入PermissionError模拟打开失败,属于桩测试;
真实的权限异常在测试环境中难以稳定复现,这是合理的取舍 - 测试未覆盖超大文本(数百 MB)的性能表现,该部分由第 4 节的压力测试
(380 KB 长文本)与性能分析单独负责 - 测试无法验证「答案数值是否与被测方期待的正确答案一致」——
这需要真实论文语料作为基准,个人作业条件下无法构造
六、计算模块部分异常处理说明
异常体系设计为四个类,全部集中在 errors.py:
class PlagiarismError(Exception):
"""本程序所有自定义异常的基类,便于在入口处统一捕获。"""
class ParameterError(PlagiarismError):
"""命令行参数的个数不正确。"""
class FileReadError(PlagiarismError):
"""输入文件不存在、无法读取或内容为空。"""
class FileWriteError(PlagiarismError):
"""答案文件无法写入。"""
统一以 PlagiarismError 为基类的好处:main() 里只需要一个
except PlagiarismError 就能兜住所有已知的业务异常,避免遗漏。
下面是四种异常各自的设计目标、对应场景与单元测试样例。
E1 · ParameterError —— 命令行参数个数不正确
设计目标:防止误用,给出正确用法。参数个数不对时不能默默算出一个错误结果,
也不能抛堆栈,而要明确告诉使用者「需要 3 个参数」。
对应场景:评测时若传参方式出错(如路径含空格导致参数被拆断),
应看到明确提示而不是 Python 报错。
抛出点(main.py):
def parse_args(argv):
if len(argv) != 4:
raise ParameterError(
"参数个数不正确:需要 3 个(原文、抄袭版、答案),"
f"实际收到 {len(argv) - 1} 个。"
)
return argv[1], argv[2], argv[3]
单元测试样例(用例 30):
def test_30_two_args_raises(self):
"""异常路径:只传 2 个参数时抛 ParameterError。"""
with self.assertRaises(ParameterError):
parse_args(["main.py", "orig.txt", "copy.txt"])
实际运行表现:
$ py -3.13 main.py
错误:参数个数不正确:需要 3 个(原文、抄袭版、答案),实际收到 0 个。
用法:python main.py [原文文件] [抄袭版论文的文件] [答案文件]
(退出码 1,标准错误输出为空,无 Python 堆栈)
E2 · FileReadError —— 输入文件不存在 / 无法读取 / 内容为空
设计目标:三种情况合并为一个异常类,但错误信息必须明确指出是哪一个文件、
以及具体原因。同时保证不论哪种情况,程序都只打印提示后正常返回。
对应场景:评测的 18 个测试点中,很可能包含「文件不存在」「文件是空的」
这类边界输入。
抛出点(file_reader.py):
# 按优先级依次尝试的编码。
# 首选用 utf-8-sig 而非 utf-8:前者能自动剥离文件开头的 BOM
# (Windows 记事本保存 UTF-8 时会写入 BOM),对不带 BOM 的文件同样有效。
ENCODINGS = ("utf-8-sig", "gbk", "gb18030")
# 字节顺序标记,个别环境下可能残留,读取后统一清除
BOM = "\ufeff"
def read_text(path):
text = None
for encoding in ENCODINGS:
try:
with open(path, "r", encoding=encoding) as file:
text = file.read()
break
except FileNotFoundError:
raise FileReadError(f"找不到文件:{path}")
except UnicodeDecodeError:
continue
except OSError as error:
raise FileReadError(f"无法读取文件:{path}({error})")
if text is None:
# 三种编码都试过了仍然失败,忽略非法字符强制解码
try:
with open(path, "rb") as file:
text = file.read().decode("utf-8", errors="ignore")
except OSError as error:
raise FileReadError(f"无法读取文件:{path}({error})")
# 清除可能残留的 BOM,避免它被当成一个词参与相似度统计
text = text.lstrip(BOM)
if not text.strip():
raise FileReadError(f"文件内容为空:{path}")
return text
三个抛出点分别对应三种情况:文件不存在(FileNotFoundError)、
文件读不出来(其它 OSError)、读出来是空的(末尾的 strip() 判断)。
交付前的端到端验收中发现一处真实缺陷:Windows 记事本保存 UTF-8 时会在文件头
写入 BOM(字节顺序标记),原实现用utf-8解码会把它保留为文本首字符
\ufeff,混进特征向量后导致同一对文件算出0.64而非0.61。
修复方式是首选用utf-8-sig编码(自动剥离 BOM),并在返回前再lstrip(BOM)
兜底。该缺陷已补回归用例 39,四种编码(UTF-8 / UTF-8-BOM / GBK / GB18030)
现均输出0.61。
单元测试样例(用例 05 —— 文件不存在):
def test_05_missing_file_raises(self):
"""异常路径:文件不存在时抛 FileReadError。"""
missing = os.path.join(self.dir, "not_exist.txt")
with self.assertRaises(FileReadError):
read_text(missing)
补充样例(用例 07 —— 空文件内容,属 E4 场景):
def test_07_empty_file_raises(self):
"""异常路径:空文件视为无效输入,抛 FileReadError。"""
path = self.make_file("empty.txt", "")
with self.assertRaises(FileReadError):
read_text(path)
实际运行表现:
$ py -3.13 main.py not_exist.txt copy.txt ans.txt
错误:找不到文件:not_exist.txt
用法:python main.py [原文文件] [抄袭版论文的文件] [答案文件]
(退出码 1,无 Python 堆栈)
E3 · FileWriteError —— 答案文件无法写入
设计目标:与 FileReadError 职责对称——读取失败归读模块,写出失败归写模块。
提示使用者检查输出目录是否存在、是否有写权限。
对应场景:答案路径指向一个不存在的目录(评测脚本传错路径时很常见)。
抛出点(answer_writer.py):
def write_answer(answer_path, similarity):
text = f"{similarity:.2f}"
try:
with open(answer_path, "w", encoding="utf-8") as file:
file.write(text)
except OSError as error:
raise FileWriteError(
f"无法写入答案文件:{answer_path}({error})"
) from error
return text
单元测试样例(用例 27):
def test_27_unwritable_path_raises(self):
"""异常路径:目录不存在时抛 FileWriteError。"""
bad_path = os.path.join(self.dir, "no_such_dir", "ans.txt")
with self.assertRaises(FileWriteError):
write_answer(bad_path, 0.61)
实际运行表现:
$ py -3.13 main.py orig.txt copy.txt no_such_dir/ans.txt
错误:无法写入答案文件:no_such_dir/ans.txt([Errno 2] No such file or directory: ...)
用法:python main.py [原文文件] [抄袭版论文的文件] [答案文件]
(退出码 1,无 Python 堆栈)
E4 · PlagiarismError —— 基类(统一捕获入口)
设计目标:本身不直接抛出,而是作为基类让入口层「一次捕获、不会遗漏」。
即便将来新增异常子类,main() 也无需修改。
对应场景:任何业务异常发生时,main() 都能统一处理。
抛出点(main.py 的入口函数):
def main():
try:
original_path, copied_path, answer_path = parse_args(sys.argv)
similarity = calc_similarity(original_path, copied_path)
write_answer(answer_path, similarity)
except PlagiarismError as error:
print(f"错误:{error}")
print(USAGE)
return 1
print(f"重复率 {similarity:.2f} 已写入 {answer_path}")
return 0
单元测试样例(用例 02 —— 验证基类可拦截全部子类):
def test_02_base_class_catches_subclass(self):
"""验证捕获基类可以拦截全部子类异常。"""
for error_type in (ParameterError, FileReadError, FileWriteError):
with self.assertRaises(PlagiarismError):
raise error_type("测试")
为什么这条测试重要:它验证的是「架构约定」而不是某个具体功能。
只要这条测试还通过,就能保证无论哪个模块抛出异常,入口层都不会漏接。
E5 · 编码降级(FileReadError 的柔性处理路径)
设计目标:编码问题不应该变成一个异常。中文文本在 Windows 下常以 GBK
保存,若只按 UTF-8 打开会抛 UnicodeDecodeError。设计目标是尽量降级读取、
不中断程序,不到万不得已不报错。
对应场景:评测方提供的测试文件若为 GBK 编码,程序必须仍能算对结果。
实现:UTF-8 → GBK → GB18030 三级回退,全部失败时忽略非法字符强制解码。
单元测试样例(用例 04 + 用例 09):
def test_04_read_gbk(self):
"""等价类:GBK 编码的中文文件应能正确读取。"""
path = os.path.join(self.dir, "gbk.txt")
with open(path, "w", encoding="gbk") as handle:
handle.write(SAMPLE_ORIGINAL)
self.assertEqual(read_text(path), SAMPLE_ORIGINAL)
def test_09_invalid_bytes_fallback(self):
"""异常路径:三种编码均失败时,忽略非法字符兜底解码。"""
path = self.make_bytes("bad.txt", b"hello\x80world")
self.assertEqual(read_text(path), "helloworld")
异常处理小结
| 异常类 | 场景编号 | 设计目标 | 抛出点 | 单元测试 |
|---|---|---|---|---|
ParameterError |
E1 | 参数个数不对时给出用法而非崩溃 | main.parse_args() |
用例 28–31 |
FileReadError |
E2 / E3 / E4 | 明确是哪个文件、什么原因读不了 | file_reader.read_text() |
用例 05/06/07/08/36 |
FileWriteError |
E6 | 提示检查输出目录与写权限 | answer_writer.write_answer() |
用例 27 |
PlagiarismError |
— | 基类,保证入口一处捕获不漏 | 不抛出,由子类继承 | 用例 01/02 |
| 编码降级路径 | E5 | 编码问题不升级为异常,尽力读出内容 | file_reader.read_text() |
用例 04/09 |
总体设计原则:所有异常都被捕获并转换成人类可读的提示信息,
绝不允许把 Python 的原始异常堆栈直接抛给用户。
这一点在实测中验证过:参数个数错误、原文文件不存在、抄袭版文件不存在、
原文为空文件、答案路径不可写,六种场景的标准错误输出全部为空,
没有任何 Python 堆栈崩溃。
七、PSP 表格(实际)
项目完成后回填的实际耗时:
PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30min | 90min |
| · Estimate | · 估计这个任务需要多少时间 | 30min | 90min |
| Development | 开发 | 600min | 580min |
| · Analysis | · 需求分析(包括学习新技术) | 90min | 60min |
| · Design Spec | · 生成设计文档 | 45min | 60min |
| · Design Review | · 设计复审 | 30min | 25min |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 20min | 20min |
| · Design | · 具体设计 | 45min | 45min |
| · Coding | · 具体编码 | 210min | 200min |
| · Code Review | · 代码复审 | 40min | 40min |
| · Test | · 测试(自我测试,修改代码,提交修改) | 120min | 130min |
| Reporting | 报告 | 120min | 105min |
| · Test Report | · 测试报告 | 45min | 45min |
| · Size Measurement | · 计算工作量 | 15min | 15min |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 60min | 45min |
| 合计 | 750min | 775min |
得到的改进经验
- 预估「读过别人的教程」和「自己动手做」之间要留 2 倍余量
- 涉及第三方库调优时,应把「做实验并否定方案」的时间也计入预算
- 文档与博客的撰写应更早介入、与编码并行,而不是全部堆在最后
八、Git 签入记录
按作业要求,功能由自己定义,并在完成每个功能、程序可正常运行后进行一次签入。
签入原则:每完成一个功能、且程序可正常运行时立即签入,
不把多次改动攒成一次提交。第 3~6 次重构的每一步都保证
程序输出的结果完全一致(短样例 0.61、异常退出码 1),
即每次提交都对应一个可独立验证的功能增量。第 11 次的性能优化
有意改变了相似度数值(0.98 ~ 1.00 → 0.75 ~ 0.97),根据实际文本肉眼对比,发现算法不够最优,得到的结果接近1.00,不准确,逐步改正精确结果。
这是修正错误判定而非破坏结果。
总结
这次作业最大的收获,是用数据推翻直觉:最初用 jieba 整词词袋已功能正确、性能达标,但实测发现相邻字互换的抄袭会被判成 0.98~1.00 的完全重复,说明词袋根本不感知词序;通过 cProfile 定位到 92.6% 的耗时在第三方分词库而非自己的代码后,我果断改为汉字 2-gram,一举去掉 53 MB 词典依赖、端到端提速 5.7 倍(2.06s→0.36s)、修复乱序误判并实现零第三方依赖;被否定的方案同样有价值——单字粒度会把乱序判成 1.000、关闭 HMM 几乎不提速(0.443s vs 0.444s),让结论更站得住脚。测试侧坚持覆盖率驱动,按 Missing 列从 98% 补到 100%,算法升级后再补 3 个顺序敏感性用例,把“特征必须携带字序信息”固化成可执行断言;异常侧用统一基类一次捕获,六种异常场景均无堆栈崩溃。教训是模块化返工本可在设计阶段先标注异常抛出点来避免,前两轮只优化自己代码仅得 1.2% 收益,印证了“先测量、再决定在哪里优化”;不足是缺少真实标注语料,目前 42 个用例只验证了行为正确,还无法评估数值与人工判断的抄袭程度是否一致,这是后续可补的方向。

浙公网安备 33010602011771号