第二周作业
| 这个作业属于哪个课程 | 课程主页 |
|---|---|
| 这个作业要求在哪里 | 作业要求 |
| 这个作业的目标 | 让我们熟悉软件开发的流程 |
作业 GitHub 链接:https://github.com/zzzky996/3124004075 (仓库中以学号命名的文件夹
3124004075,源码与测试用例均按进展签入)
学号:3124004075 | 项目:论文查重(个人编程作业)| 实现语言:Python 3(运行时零第三方依赖)
本文严格按照作业评分细则的顺序组织:PSP 预估 → 设计与实现 → 性能改进 → 单元测试 → 异常处理 → PSP 实际耗时。
作业要求速览:从命令行接收三个绝对路径参数(原文、抄袭版、答案文件),程序读取文件、计算重复率、输出浮点型结果并精确到小数点后两位;共 18 个测试点(不含样例),要求 5 秒内出答案、内存不超过 2048MB、不得异常退出;同时要求 PSP 表格记录预估与实际耗时、GitHub 管理代码、单元测试不少于 10 个并展示分支覆盖率、代码质量分析消除警告、性能分析工具找瓶颈并改进。
一、PSP 表格:开发前预估
按照 PSP 的要求,下表「预估耗时」在开始编码之前填写。预估的依据:作业要求阅读与需求分析(18 个测试点、5 秒 / 2048MB 约束)、两条技术路线的对比研究(jieba vs 字符 n-gram)、四模块拆分与函数签名约定、42 个测试用例的编写与调试。
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 编码后填写 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 编码后填写 |
| Development | 开发 | 430 | 编码后填写 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 编码后填写 |
| · Design Spec | · 生成设计文档 | 30 | 编码后填写 |
| · Design Review | · 设计复审 | 15 | 编码后填写 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 编码后填写 |
| · Design | · 具体设计 | 40 | 编码后填写 |
| · Coding | · 具体编码 | 150 | 编码后填写 |
| · Code Review | · 代码复审 | 30 | 编码后填写 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 编码后填写 |
| Reporting | 报告 | 70 | 编码后填写 |
| · Test Report | · 测试报告 | 30 | 编码后填写 |
| · Size Measurement | · 计算工作量 | 15 | 编码后填写 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 25 | 编码后填写 |
| 合计 | 520 | 编码后填写 |
预估总量 520 分钟,其中编码 + 测试占 240 分钟(46%),是占比最大的部分;需求分析 60 分钟用于把"18 个测试点、5 秒、2048MB"这些约束翻译成具体的技术决策。
二、计算模块接口的设计与实现过程
2.1 代码如何组织:4 个模块、8 个函数、5 个异常类
考虑到本项目规模(约 240 行),我采用纯函数风格而非面向对象:不定义业务类,用 4 个模块 + 8 个函数完成"读取 → 清洗 → 计算 → 写出"的流水线。核心原则是把 IO 与纯计算分离,让算法可以脱离文件系统做单元测试。
| 模块 | 函数 / 异常类 | 职责 |
|---|---|---|
main.py |
read_text(path)、main(argv) |
入口:解析命令行参数、读文件、组装流水线、写答案、统一异常兜底 |
text_processor.py |
normalize(text)、clean(text) |
纯字符串处理:全角转半角、统一引号、去空白标点 |
similarity.py |
char_ngrams(text, n)、build_vector(text, n)、cosine_similarity(a, b)、similarity(a, b, n) |
核心算法:n-gram 生成、词频向量、余弦相似度 |
exceptions.py |
PaperCheckerError 及 4 个子类 |
自定义异常体系,统一业务错误语义 |
模块依赖关系(无环,exceptions.py 被所有模块引用):
函数间关系:main(argv) 依次调用 read_text(×2)→ clean(×2)→ similarity → 写文件;similarity 内部调用 build_vector(×2)→ cosine_similarity;build_vector 调用 char_ngrams(生成器)。数据流单向,无回环。
2.2 关键函数流程图
以 main() 为例(这是唯一涉及 IO 与异常兜底的函数,也是最容易出错的地方):
2.3 算法关键与独到之处
算法关键(不列出源代码):文本清洗(全角转半角、统一中文引号、去空白标点、小写化)→ 把清洗后的文本切成固定长度字符窗口(默认二元字符组 bigram)→ 统计每个窗口出现次数构成词频向量 → 计算两个向量的余弦相似度作为重复率。选择 bigram 的依据:增、删、改只影响局部窗口,整体分布变化有限,重复率能落在合理量级;且窗口过小区分度不足、过大对局部篡改过于敏感,实测 n=2 与官方样例期望(0.61)完全吻合。
独到之处:
- 零第三方运行时依赖:不引入 jieba 等分词库,保证程序在任何受限环境(无网、pip 不可用)都能运行、可复现,也更容易通过"重新编译验证逻辑相同"的检查;
- IO 与计算彻底分离:
text_processor与similarity内部没有任何文件操作,42 个单元测试完全在内存中进行,不依赖测试数据文件; - 清洗顺序精心设计:先做字符归一(全角→半角、引号统一),再去标点——否则"今天是星期天"与"今天是,星期天"会因标点差异被拆成不同 n-gram,白白拉低重复率;
- 异常统一兜底:所有业务错误翻译为自定义异常,入口处统一捕获,任何输入都不会导致程序异常退出(对应测试点"异常退出即不过"的硬要求);
- 生成器惰性求值:
char_ngrams用生成器而非列表,避免 20 万字输入下一次性构造约 20 万个字符串中间列表,同时省内存、省时间。
三、计算模块接口部分的性能改进
3.1 改进所花费的时间
性能剖析与优化共花费 约 20 分钟(计入 PSP 的 Coding 与 Code Review 阶段)。
3.2 改进思路
改进前(首个版本):char_ngrams 用列表推导式一次性生成全部 n-gram,build_vector 用 Python 层的手动 dict 计数循环,余弦点积遍历两个向量的并集。
改进思路(按收益排序):
- 词频统计改用
collections.Counter:Counter(生成器)一次完成统计,计数逻辑落在 C 层实现(_count_elements),避免 Python 层逐项dict.get + 1循环; - n-gram 生成改为生成器:惰性求值,消除中间列表,减少内存分配与 GC 压力;
- 余弦点积遍历较短向量:
keys = 较短一方.keys()并对另一方做in判断,减少无效查找次数; - 文本清洗用
str.translate+ 预编译正则:全角转半角用预构造的映射表一次translate完成,替代逐字符替换。
3.3 性能分析图(cProfile 自动生成)
由于本项目选择 Python 实现,使用 Python 标准库 cProfile 作为性能分析工具(等价于 VS 2017 / JProfiler 的剖析功能),对 20 万字输入进行剖析,自动生成如下性能分析图:

3.4 程序中消耗最大的函数
从 cProfile 的 cumtime(累计耗时) 看,消耗最大的函数是 similarity.py 的 build_vector(n-gram 词频统计),累计耗时 0.088s(占剖析总时间 0.154s 的 57%)。其内部构成:
| 消耗点 | 数据 |
|---|---|
char_ngrams 生成器调用次数 |
397,134 次(耗时 0.042s) |
Counter.update 底层 C 计数(_count_elements) |
0.046s |
build_vector 累计耗时 |
0.088s |
结论:性能热点在"生成 n-gram + 计数"这一对操作上,而不是余弦计算本身(余弦只有约 0.004s)——这也印证了 3.2 中把优化火力集中在词频统计环节的正确性。
3.5 改进效果(实测)
| 指标 | 改进前 | 改进后 | 提升 |
|---|---|---|---|
| 20 万字纯算法计算(中位数) | 0.0765s | 0.0550s | 加速 1.39× |
| 端到端:官方量级约 1 万字 | — | 约 0.12s(含解释器启动) | — |
| 端到端:20 万字 | — | 约 0.26s | — |
两项端到端耗时均远低于 5 秒上限(余量约 20~40 倍),内存占用也远低于 2048MB——优化后程序的性能余量非常充足。
四、计算模块部分单元测试展示
4.1 部分单元测试代码
全部 42 个用例写在 test_paper.py 中(标准库 unittest,无需额外依赖)。以下节选四段有代表性的测试:
def test_similarity_sample_sunday_case_range(self):
"""作业样例:星期天 -> 周天 属于同义改写,重复率应落在一个合理区间。"""
rate = similarity("今天是星期天天气晴今天晚上我要去看电影",
"今天是周天天气晴朗我晚上要去看电影")
self.assertGreaterEqual(rate, 0.4)
self.assertLessEqual(rate, 0.9)
def test_main_identical_texts_outputs_100(self):
"""完全相同文本:答案文件应输出 1.00(验证格式化)。"""
main([self.orig, self.orig, self.ans])
with open(self.ans, "r", encoding="utf-8") as f:
self.assertEqual(f.read().strip(), "1.00")
def test_main_non_utf8_file_raises(self):
"""GBK 编码文件:应抛 UnsupportedEncodingError 而非乱码崩溃。"""
gbk_file = os.path.join(self.dir, "gbk.txt")
with open(gbk_file, "wb") as f:
f.write("中文测试".encode("gbk"))
with self.assertRaises(UnsupportedEncodingError):
main([gbk_file, self.copy, self.ans])
def test_main_unwritable_output_raises(self):
"""输出目录不存在:应抛 OutputWriteError。"""
bad_ans = os.path.join(self.dir, "no_dir", "ans.txt")
with self.assertRaises(OutputWriteError):
main([self.orig, self.copy, bad_ans])
4.2 测试函数与构造测试数据的思路
测试按被测对象组织成 4 个测试类:
| 测试类 | 用例数 | 测试对象 | 构造测试数据的思路 |
|---|---|---|---|
TextProcessorTest |
10 | 清洗模块 | 覆盖全角/半角、中文引号、空白、标点、大小写、非字符串输入 |
SimilarityTest |
12 | 相似度模块 | 覆盖 n-gram 数量/边界、向量计数、余弦的 0 / 1 / 空向量 / 相似向量、作业样例区间 |
MainIntegrationTest |
12 | 主程序端到端 | 用 tempfile 生成真实文件,覆盖正常路径、参数错误、各类异常输入、输出格式 |
ExceptionTest |
8 | 自定义异常 | 覆盖异常消息内容、继承关系、可捕获性 |
构造思路的核心:优先覆盖"最可能翻车"的输入——空文件、纯标点文件、超短文本、GBK 编码、输出目录不存在,而不是只测正常路径。这正对应 18 个测试点里"异常退出、5 秒超时、内存超限"的扣分项。
测试数据文件共 9 个(test_data/):orig.txt(原文)、orig_0.8_add.txt(增)、orig_0.8_del.txt(删)、orig_0.8_dis_1.txt(改)、different.txt(无关)、orig_sample.txt / copy_sample.txt(作业样例)、empty.txt(空文件)、punctuation_only.txt(纯标点)。
4.3 实测结果(供测试报告引用)

测试语料为官方下发的《活着》原文(orig.txt,约 1 万字)及其抄袭版,实测重复率:
| 输入场景 | 实测重复率 |
|---|---|
| 原文 vs 原文(完全相同) | 1.00 |
原文 vs orig_0.8_add.txt(随机插字,增) |
0.90 |
原文 vs orig_0.8_del.txt(随机删字,删) |
0.94 |
原文 vs orig_0.8_dis_1.txt(随机换字,改) |
0.92 |
原文 vs different.txt(无关文本) |
0.02 |
| 作业样例(星期天 / 周天) | 0.61 |
几点观察:删字后重复率反而最高(0.94),因为删除不引入新字符;无关文本 0.02,干净地分开了"抄了"与"没抄";样例 0.61 说明短文本上字符 bigram 对同义改写仍有一定损失,但量级合理。另以同量级自造语料本地复现,增/删/改场景重复率落在 0.78~0.93、无关文本 0.01、样例恰好 0.61,与官方语料结论一致。
4.4 测试覆盖率截图
42 个用例全部通过(Ran 42 tests ... OK),并用 coverage.py --branch 统计语句与分支覆盖率:

| 模块 | 语句覆盖率 |
|---|---|
exceptions.py |
100% |
text_processor.py |
100% |
similarity.py |
93% |
main.py |
82% |
| 总计 | 95%(语句) / 约 88%(分支) |
未覆盖的 8 条语句集中在 main.py 的 if __name__ == "__main__" 入口兜底块(sys.exit 分支)——该块属于进程级代码,不便于也不需要在单元测试中执行,属于可接受的覆盖缺口。
五、计算模块部分异常处理说明
异常体系设计目标:把底层错误翻译成业务语义,并在入口统一兜底,保证任何输入都不会让程序异常退出(对应测试点"发生异常退出即判不过")。exceptions.py 定义 1 个基类 + 4 个业务异常。以下逐个说明每种异常的设计目标,并各附一个单元测试样例与对应场景。
5.1 PaperCheckerError(基类)
设计目标:作为所有业务异常的公共父类,让 main.py 入口只需 except PaperCheckerError 即可统一捕获全部业务错误并打印可读信息,避免层层嵌套的异常分支。
测试样例(ExceptionTest.test_raise_and_catch_custom_exception):
def test_raise_and_catch_custom_exception(self):
with self.assertRaises(PaperCheckerError):
raise FileNotExistError("测试")
对应场景:任何业务异常发生时,验证其都能被基类捕获——这是"程序不异常退出"的最后一层保证。
5.2 FileNotExistError
设计目标:命令行传入的文件路径不存在(路径拼错、文件被移动/删除)时,把底层的 FileNotFoundError 翻译为带路径信息的业务异常,提示用户具体是哪个文件出了问题。
测试样例(MainIntegrationTest.test_main_missing_orig_file_raises):
def test_main_missing_orig_file_raises(self):
with self.assertRaises(FileNotExistError):
main([os.path.join(self.dir, "no_such.txt"), self.copy, self.ans])
对应场景:python main.py C:\no_such\orig.txt ...——原文文件不存在。
5.3 EmptyFileError
设计目标:输入文件为空、或清洗后没有有效内容(只有空白/标点)时,不应参与相似度计算——否则会产生空向量,导致余弦除零或返回无意义的 0,掩盖"文件其实没内容"的真实问题。
测试样例(MainIntegrationTest.test_main_empty_input_file_raises 与 test_main_punctuation_only_raises):
def test_main_empty_input_file_raises(self):
empty = os.path.join(self.dir, "empty.txt")
with open(empty, "w", encoding="utf-8") as f:
f.write("")
with self.assertRaises(EmptyFileError):
main([empty, self.copy, self.ans])
对应场景:空文件;或文件内容全是标点(如 !?,。),清洗后为空串。
5.4 UnsupportedEncodingError
设计目标:文件编码不是 UTF-8(如 GBK)时,open(..., encoding="utf-8") 会抛 UnicodeDecodeError,若不处理程序会带着乱码或直接崩溃。该异常把它翻译成明确提示,让用户知道是编码问题而不是算法问题。
测试样例(MainIntegrationTest.test_main_non_utf8_file_raises):
def test_main_non_utf8_file_raises(self):
gbk_file = os.path.join(self.dir, "gbk.txt")
with open(gbk_file, "wb") as f:
f.write("中文测试".encode("gbk"))
with self.assertRaises(UnsupportedEncodingError):
main([gbk_file, self.copy, self.ans])
对应场景:Windows 下常见的 GBK 编码文本文件作为输入。
5.5 OutputWriteError
设计目标:答案文件路径不可写(目录不存在、无权限)时,open(ans_path, "w") 会抛 OSError;该异常给出明确提示,避免程序带着 Traceback 退出。
测试样例(MainIntegrationTest.test_main_unwritable_output_raises):
def test_main_unwritable_output_raises(self):
bad_ans = os.path.join(self.dir, "no_dir", "ans.txt")
with self.assertRaises(OutputWriteError):
main([self.orig, self.copy, bad_ans])
对应场景:答案文件指向一个不存在的目录,如 C:\tests\no_such_dir\ans.txt。
六、PSP 表格:开发后实际耗时
实现、测试、报告全部完成后,在 PSP 表格中如实记录实际耗时(对照第一节的预估):
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 25 |
| Development | 开发 | 430 | 477 |
| · Analysis | · 需求分析(包括学习新技术) | 60 | 70 |
| · Design Spec | · 生成设计文档 | 30 | 35 |
| · Design Review | · 设计复审 | 15 | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 12 |
| · Design | · 具体设计 | 40 | 45 |
| · Coding | · 具体编码 | 150 | 170 |
| · Code Review | · 代码复审 | 30 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 100 |
| Reporting | 报告 | 70 | 77 |
| · Test Report | · 测试报告 | 30 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 12 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 25 | 30 |
| 合计 | 520 | 579 |
各阶段在项目中的具体含义:
| 阶段 | 本项目中对应的具体工作 |
|---|---|
| Estimate | 阅读作业要求,评估 18 个测试点、5 秒 / 2048MB 约束带来的工作量 |
| Analysis | 对比中文分词(jieba)与字符 n-gram 两条技术路线,确定「零第三方依赖」的选型 |
| Design Spec | 划分 exceptions / text_processor / similarity / main 四个模块,约定函数签名 |
| Design Review | 复审模块边界:确认 IO 与纯计算分离,保证核心算法可脱离文件系统做单元测试 |
| Coding Standard | 统一命名规范(模块下划线、函数小写下划线)、统一中文注释风格 |
| Design | 设计「清洗 → n-gram 词频向量 → 余弦相似度」的计算流水线,并设计异常兜底策略 |
| Coding | 实现四个模块,共约 240 行源码;实现 test_paper.py 共 42 个测试用例 |
| Code Review | 检查异常分支、编码兼容、结果格式化(两位小数)等易错点;性能剖析与优化约 20 分钟计入本阶段 |
| Test | 运行 python -m unittest,逐个场景验证增 / 删 / 改 / 无关 / 空文件等输入 |
| Test Report | 汇总测试通过率、覆盖率与各测试场景下的重复率取值 |
| Size Measurement | 统计代码行数、函数个数与测试用例数 |
| Postmortem | 复盘:字符 bigram 对超短文本(如样例)区分度一般,可考虑加入词级特征互补 |
工作量统计(Size Measurement):源码模块数 4 个(main.py、text_processor.py、similarity.py、exceptions.py);源码总行数约 240 行(含注释与文档字符串);单元测试用例数 42 个;测试数据文件数 9 个(含官方语料 orig.txt、orig_0.8_add.txt);第三方运行时依赖 0 个。
复盘(Postmortem):总量偏差 +11.3%(579 vs 520),属于可接受范围,但反映出"计划阶段对编码工作量的估计系统性偏低"。偏差最大的两项:Analysis(+10 分钟,技术路线对比研究比预想费时,但选型正确省下了后续几十倍时间)与 Coding(+20 分钟,主要花在异常兜底和编码兼容这类"题目里没明说但测试点会考"的隐性需求上)。Coding Standard 反而省了 3 分钟——零依赖选型确定后,规范只需统一命名与注释风格。改进计划:下一版可引入词级特征与 bigram 互补,兼顾长短文本的区分度。

浙公网安备 33010602011771号