第一次个人编程作业
https://github.com/zayuchips/3124004167
| 这个作业属于哪个课程 | 计科24级78班 软件工程 |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702 |
| 这个作业的目标 | 设计一个论文查重算法,给出一个原文文件和一个在这份原文上经过了增删改的抄袭版论文的文件,在答案文件中输出其重复率。 |
一、PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 180 |
| · Estimate | · 估计这个任务需要多少时间 | 20 | 240 |
| Development | 开发 | 320 | 640 |
| · Analysis | · 需求分析(包括学习新技术) | 45 | 120 |
| · Design Spec | · 生成设计文档 | 30 | 10 |
| · Design Review | · 设计复审 | 20 | 20 |
| · Coding Standard | · 代码规范 | 15 | 30 |
| · Design | · 具体设计 | 40 | 60 |
| · Coding | · 具体编码 | 100 | 120 |
| · Code Review | · 代码复审 | 20 | 20 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 50 | 90 |
| Reporting | 报告 | 90 | 30 |
| · Test Report | · 测试报告 | 30 | 10 |
| · Size Measurement | · 计算工作量 | 15 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 45 | 20 |
| 合计 | 430 | 1610 |
说明:预估列是开工前按任务拆解填写的草稿,实际列在编码、测试完成后补录。实际开发中「具体编码」与「测试」两项通常最容易超时,原因写在第六节。
二、计算模块接口的设计与实现过程
2.1 需求理解与功能划分
题目要求设计一个论文查重算法:给出原文文件与一份在其基础上经过增删改的抄袭版论文文件,在答案文件中输出重复率,且答案必须是浮点型、精确到小数点后两位。输入输出全部通过命令行三个参数给出绝对路径,程序本身不产生除此之外的任何文件读写。
据此我把需求划分成两块,也对应两次代码签入:
| 划分 | 内容 |
|---|---|
| 基本功能 | 命令行参数解析 → 读取两个文本文件 → 计算相似度(SimHash 指纹 + 海明距离)→ 把结果按两位小数写入答案文件 |
| 扩展功能 | 异常处理(参数个数、文件不存在、编码错误、空文本)+ 性能优化(词频加权、分词结果复用)+ 单元测试与覆盖率 |
2.2 代码组织
程序采用「单一职责 + 纯函数为主」的组织方式:所有计算逻辑都是无副作用的纯函数,输入输出只集中在 read_text 与 write_result 两个函数里,这样每个函数都能被单元测试直接调用,不需要构造文件即可验证算法。
| 函数 | 职责 | 是否纯函数 |
|---|---|---|
read_text(path) |
读取 UTF-8 文本,处理文件不存在与编码错误 | 否(I/O) |
tokenize(text) |
jieba 分词,去标点、过滤单字 | 是 |
word_hash(word, bits=64) |
单词映射为稳定的 64 位无符号哈希 | 是 |
simhash(text, bits=64) |
加权 → 降维 → 生成 64 位文本指纹 | 是 |
hamming_distance(h1, h2) |
计算两个指纹不同的二进制位数 | 是 |
similarity(dist, bits=64) |
海明距离换算为相似度,保留两位小数 | 是 |
parse_args(argv) |
解析并校验命令行参数 | 是 |
write_result(path, value) |
把结果写入答案文件 | 否(I/O) |
main(argv) |
串联全流程,统一捕获异常并转成中文提示 | 否(入口) |
调用关系如下(单向、无环):
main()
├─ parse_args() 解析命令行参数(原文 / 抄袭版 / 答案文件)
├─ read_text() ×2 读取两篇文本
├─ simhash() ×2
│ ├─ tokenize() jieba 分词 + 去标点 + 过滤单字
│ └─ word_hash() md5 → 64 位哈希
├─ hamming_distance() → similarity()
└─ write_result() 写入答案文件(两位小数)
这种组织方式的直接收益:测试不需要造文件。例如验证「完全相同文本的重复率应为 1.00」时,只要比较两个 simhash() 的返回值和 similarity() 的输出即可,测试关注的是算法行为而不是文件系统。
2.3 算法的关键
(1)为什么选 SimHash。 题目给的样例是「今天是星期天,天气晴,今天晚上我要去看电影。」对比「今天是周天,天气晴朗,我晚上要去看电影。」——两句只有「星期天/周天」「晴/晴朗」等少数词不同。逐字符比对会被标点与改写打乱(差异率高),基于词频向量的余弦相似度需要维护两个完整词向量,而 SimHash 把整段文本压缩成固定 64 位的指纹,比较成本是一个异或运算,特别适合「快速拿一个重复率」的场景。
(2)SimHash 四步:
- 分词:
jieba.lcut(text)得到词列表,去掉纯标点与长度为 1 的单字; - 词哈希:每个词映射为 64 位哈希值
h(用hashlib.md5,见 2.4); - 加权降维:初始化 64 维向量
v,对每个词逐位判断 —— 若h第i位为 1 则v[i] += weight,否则v[i] -= weight(weight取该词的词频); - 生成指纹:
v[i] > 0则该位取 1,否则取 0,得到 64 位指纹。
(3)官方样例实测(老师下发的样例集,orig.txt 与各抄袭版对比):
| 样例 | 海明距离 | 重复率 | 判定 |
|---|---|---|---|
| orig_0.8_add.txt | 10 | 0.84 | 相似 |
| orig_0.8_del.txt | 12 | 0.81 | 相似 |
| orig_0.8_dis_1.txt | 8 | 0.88 | 相似 |
| orig_0.8_dis_10.txt | 12 | 0.81 | 相似 |
| orig_0.8_dis_15.txt | 14 | 0.78 | 相似 |
| none.txt(0 字节) | 64 | 0.00 | 不相似(程序不报错退出) |
| chaos.txt(乱码) | 30 | 0.53 | 不相似 |
样例名里的 0.8 是构造抄袭版时的相似度目标。本实现给出 0.780.88,比余弦相似度的实现(0.900.98)更贴近 0.8 —— SimHash 的按位多数表决对少量增删改更宽容,而余弦相似度会被词频差异放大。同一份样例在不同算法下数值不同,说明写清算法口径比追求某个固定数字更重要。
(4)相似度换算。 两个指纹的差异用海明距离衡量:
d = popcount(h1 XOR h2) # 不同的二进制位数,取值 0 ~ 64
重复率 = 1 - d / 64 # 保留两位小数
完全相同 → d = 0 → 1.00;完全无关 → 距离通常在 20 以上。业界经验阈值取 d ≤ 16 判为「相似」,我在程序里也按这个口径给出「相似 / 不相似」的判定,但阈值只是参考:它衡量的是词面重合度,语义相近但全部换词的文本仍会被判为不相似(第五节测试里第三组用例就是这种情况,我会在总结里说明)。
2.4 本实现的独到之处
- 词哈希用
hashlib.md5而不是 Python 内置hash()。 内置hash()对字符串启用了按进程随机加盐(PYTHONHASHSEED),同一个词在不同进程里得到的哈希值不同,会导致同一对文本两次运行得到不同重复率,测试会「时好时坏」。改用 md5 摘要取低 64 位后,指纹完全可复现,这也是单元测试test_deterministic_across_calls专门守护的行为。 - 按词频加权 + 过滤单字。 「的/了/是」这类单字在所有中文文本里都高频出现,对区分度是负贡献,因此分词后直接丢弃;保留的词用出现次数作为权重,「软件工程」「查重」这类核心词对指纹的影响被放大,重复率更贴近人的直觉。
- 计算与 I/O 分离。 参见 2.2:算法是纯函数,I/O 只有两处。好处一是可测(21 个用例里有 12 个完全不碰文件系统,只验证纯函数的算法行为),二是评测时不会因为多余的文件读写踩到「尝试读写其他文件」的红线。
三、计算模块接口部分的性能改进
3.1 改进思路
初版实现是最直白的写法:每个词都单独算一次哈希,逐位累加到向量里。用 cProfile 分析后发现瓶颈有两处:
jieba首次加载词典(一次性初始化开销);- 逐词逐位循环的加权降维,在长文本上被重复执行多次。
改进措施:
- 分词只做一次,把结果交给后续所有步骤复用,避免重复分词;
- 用
collections.Counter先统计词频,把「按词遍历」变成「按唯一词遍历」,时间复杂度从 O(N)(N 为总词数)降到 O(V)(V 为唯一词数); - 位运算改为一次性掩码累加,减少解释器层面的循环次数。
3.2 性能分析图
采集方式(两个命令都用真实样例):
python -m cProfile -o profile_stats main.py 样例/orig.txt 样例/orig_0.8_add.txt ans.txt
snakeviz profile_stats # 浏览器打开交互式冰柱图

3.3 消耗最大的函数
python -m cProfile -o profile_stats main.py 样例/orig.txt 样例/orig_0.8_add.txt ans.txt 的统计结果(按 tottime 排序):
总函数调用: 551018,总耗时: 2.870 s
1.818 s {built-in method marshal.load} ← jieba 加载词典缓存(一次性开销,占 63%)
0.110 s jieba/__init__.py:180(get_DAG) ← 分词构建词图
0.088 s jieba/finalseg/__init__.py:37(viterbi) ← 未登录词的动态规划
0.084 s main.py:40(simhash) ← 本程序自己的位运算部分(约占 3%)
0.046 s {method 'get' of 'dict' objects}
结论:耗时最大的不是本程序的计算逻辑。 simhash() 自身只占 0.084 s(约 3%),排第一的 marshal.load(1.818 s)是 jieba 首次加载词典文件,属于一次性初始化开销;其余大头是 get_DAG 与 viterbi,都在 jieba 分词内部。这说明优化方向不在我的算法上 —— 把 64 位循环换成 bit_count() 之类的微优化收益极小,真正的空间在于避免重复加载词典(例如进程内复用、或对超长文本按段落分批处理)。在 5 秒的评测预算内,当前实现无需进一步优化。
3.4 改进前后对比
| 指标 | 初版(未优化) | 当前版本 | 说明 |
|---|---|---|---|
| 单次运行耗时(官方样例对,29KB) | 【待填:你实现初版时的耗时】 | 2.40 ~ 2.56 s(3 次实测) | 主体是 jieba 首次加载词典(约 1.8 s),与文本长度几乎无关 |
| 相同文件对的耗时(扩展功能前后) | 2.19 s(每篇文章都单独分词) | 2.00 s(内容相同时复用指纹,只分词一次) | 省下一次分词,约 0.19 s |
| 长文本(约 10 万字)耗时 | 【待填】 | 【待填】 | 建议按段落分批处理后再测 |
| 内存峰值 | 【待填】 | 111.5 MB(红线 2048 MB) | /usr/bin/time -l 的 maximum resident set size |
改进耗时约【待填】分钟。当前版本已做的优化:① 分词结果只算一次并复用;② 用
Counter把「按词遍历」降为「按唯一词遍历」,复杂度从 O(N) 降到 O(V);③ 位运算集中在一层循环内完成;④(扩展功能)两份文件内容完全相同时复用同一个指纹,跳过第二次分词。
四、计算模块部分单元测试展示
4.1 测试设计思路
测试用 Python 标准库 unittest 编写,运行方式 python -m unittest -v。用例按「等价类 + 边界值」设计,覆盖三组核心等价类(完全相同 / 部分改写 / 完全无关)、四类边界(空文本、缺参数、文件不存在、编码错误)与两项工程约束(指纹可复现、长文本性能预算),另加一组端到端用例(直接调用 main() 验证命令行行为与答案文件),共 21 个用例:
| 测试类 | 用例 | 验证点 |
|---|---|---|
| TestTokenizer | test_drops_punctuation_and_single_chars |
分词丢掉标点与单字 |
| TestTokenizer | test_blank_text_has_no_words |
空白文本分词结果为空 |
| TestSimHashCore | test_identical_text_distance_zero |
完全相同 → 距离 0、重复率 1.00 |
| TestSimHashCore | test_family_friendly_text_small_distance |
仅改虚词标点 → 距离很小 |
| TestSimHashCore | test_rewritten_text_between |
改写文本比无关文本更相似 |
| TestSimHashCore | test_unrelated_text_large_distance |
完全无关 → 距离大于阈值 |
| TestSimHashCore | test_deterministic_across_calls |
指纹可复现(守护 md5 哈希) |
| TestSimHashCore | test_similarity_is_two_decimals |
0~64 全距离下均为两位小数 |
| TestSimHashCore | test_long_text_performance_budget |
长文本在 5 秒预算内 |
| TestBoundary | test_empty_text_raises |
空文本抛 ValueError |
| TestBoundary | test_missing_file_raises |
文件不存在抛 FileNotFoundError |
| TestBoundary | test_bad_encoding_raises |
GBK 文件抛 UnicodeDecodeError |
| TestBoundary | test_parse_args_rejects_wrong_count |
参数个数错误 → 退出码 1 |
| TestBoundary | test_parse_args_ok |
三个参数正确解析 |
| TestOutput | test_write_result_two_decimals |
答案文件为两位小数 |
| TestEndToEnd | test_main_identical_files_full_rate |
端到端:两文件相同 → 答案 1.00 |
| TestEndToEnd | test_main_partial_copy_writes_two_decimals |
端到端:答案形如 0.84 |
| TestEndToEnd | test_main_empty_copy_writes_zero_and_exits_normally |
0 字节抄袭版 → 0.00 且不异常退出 |
| TestEndToEnd | test_main_empty_orig_writes_zero_and_exits_normally |
0 字节原文 → 0.00 且不异常退出 |
| TestEndToEnd | test_main_missing_file_returns_one |
文件不存在 → 返回 1,不抛未捕获异常 |
| TestEndToEnd | test_main_wrong_arg_count_exits_with_one |
参数个数不对 → 退出码 1 |
最后两条「空文件」用例是在开发中补出来的:最初只覆盖了"抄袭版为空",
实现时因为一个变量名拼写错误(
orig_hash写成otig_hash)导致原文为空时崩溃,
单元测试没覆盖到、是代码质量工具(ruff 的
F841警告)先报了出来。补齐这条用例后,
该分支的崩溃问题被固化守护。这就是"测试用例是怎么设计出来的"的真实过程。
4.2 部分测试代码
class TestSimHashCore(unittest.TestCase):
def test_identical_text_distance_zero(self):
"""完全相同文本 → 距离 0、相似度 1.00。"""
d = hamming_distance(simhash(TEXT_A), simhash(TEXT_A))
self.assertEqual(d, 0)
self.assertEqual(similarity(d), 1.0)
def test_deterministic_across_calls(self):
"""同一文本的指纹必须可复现 —— 这条例行抓“用了内置 hash()”的坑。"""
self.assertEqual(simhash(TEXT_A), simhash(TEXT_A))
def test_similarity_is_two_decimals(self):
"""答案要求精确到小数点后两位。"""
for d in range(0, 65):
self.assertEqual(similarity(d), round(similarity(d), 2))
self.assertEqual(similarity(0), 1.0)
self.assertEqual(similarity(64), 0.0)
class TestBoundary(unittest.TestCase):
def test_bad_encoding_raises(self):
"""非 UTF-8 文件要给出可读异常,而不是崩栈。"""
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
f.write("这是一个 GBK 文件".encode("gbk"))
path = f.name
try:
with self.assertRaises(UnicodeDecodeError):
read_text(path)
finally:
os.unlink(path)
4.3 运行结果与覆盖率
运行方式:
python -m unittest -v # 21 个用例,全部通过
coverage run --branch -m unittest && coverage report -m && coverage html
实测结果:21 个用例全部通过(Ran 21 tests in 2.012s / OK),分支覆盖率如下(coverage report -m 原始输出):
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------
main.py 89 7 18 1 93% 111-114, 134-136, 150
test.py 123 1 4 1 98%
-----------------------------------------------------
TOTAL 212 8 22 2 96%


五、计算模块部分异常处理说明
程序采用「下层抛语义明确的异常,main() 统一捕获并转成中文提示」的策略,保证任何异常都不会以 Traceback 形式崩给评测程序(评测规则中「发生异常退出」会扣分)。
| 异常 | 设计目标 | 对应场景 | 单元测试样例 |
|---|---|---|---|
| 参数个数错误 | 防止取参越界;给出正确用法 | 用户只传了两个路径或直接双击运行 | test_parse_args_rejects_wrong_count |
| 文件不存在 | 明确区分「路径写错」而不是崩溃 | 传入了不存在的原文/抄袭版路径 | test_missing_file_raises |
| 编码错误 | 把编码问题与文件缺失区分开,提示用户转码 | 传入了 GBK 等非 UTF-8 文件 | test_bad_encoding_raises |
| 空文本 / 除零 | 避免产生「空文件之间 100% 相似」的假结果 | 文件为 0 KB 或整篇只有空白字符 | test_empty_text_raises |
各异常的实现要点:
- 参数个数错误:
parse_args()检查len(argv) != 3,直接打印用法:python main.py [原文文件] [抄袭版论文的文件] [答案文件]并以退出码 1 结束,不进入后续流程。 - 文件不存在:
read_text()捕获FileNotFoundError并抛出带文件名的中文提示,main()捕获后打印错误:找不到文件 xxx并退出。 - 编码错误:
read_text()捕获UnicodeDecodeError,提示「文件不是 UTF-8 编码,请先转码」。 - 空文本:
simhash()在分词结果为空时抛ValueError,避免 64 维向量全为 0 导致错误判定;这是「防御性编码」,也是最小可复现的除零类风险。
六、总结与反思
1. 这次踩到的三个真实问题
(1)一个字母的拼写错误,是代码质量工具先发现、而不是我先发现的。
主体实现写完跑单元测试时,21 个用例挂了 1 个,同时 ruff 报出 F841 Local variable 'otig_hash' is assigned to but never used(有个变量赋值了却从没被读过)。顺着这条警告看过去,发现 orig_hash = copy_hash = None 被我打成了 otig_hash。这一个字母造成两种故障:抄袭版为空时程序在打印指纹那行崩溃(TypeError),原文为空时直接 UnboundLocalError。
(2)使用场景没有想的很完善
最初只写了「抄袭版为空」的用例,没想到「原文为空」也要单独覆盖。这两个场景在代码里走的是同一段 except ValueError,行为却因为一个变量没被赋值而完全不同。
(3)用 cProfile 测过,才知道该优化哪里。
动手前我猜"64 位循环是瓶颈";cProfile 跑完发现总耗时 2.87 s 里 marshal.load(jieba 加载词典)占 1.818 s(63%),我自己的 simhash() 只占 0.084 s(约 3%)。
2. 一次真实的性能改进
扩展功能里加了「两份文件内容完全相同时复用同一个指纹、跳过第二次分词」的短路逻辑。实测同一对文件(orig.txt vs orig.txt):改前 2.19 s → 改后 2.00 s,省下一次分词约 0.19 s;同时保证完全相同文本必然输出 1.00。
3. PSP 预估与实际的差距
开发和学新技术用了很久,因此和预计的用时差了很多。
4. 下一步想改进的地方
- 长文本(10 万字以上)还没有实测数据,可以按段落分批处理后补测;
- 分词后目前只过滤了单字,可以引入更完整的停用词表提升区分度;
浙公网安备 33010602011771号