第一次个人编程作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/ |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/15693 |
| 这个作业的目标 | 编码实现个人项目“论文查重”,通过github进行版本管理并写一篇技术博客 |
本项目 GitHub 仓库:https://github.com/Jiyue0321/gdut-homework1

本文是软件工程课程第一次个人项目的总结博客。项目要求实现一个论文查重命令行工具:给定原文、待查重文件和答案文件三个路径,输出 0.00~1.00 的重复率。我以 SimHash + Hamming 距离作为主算法,TF 余弦相似度作为对照,下面从 PSP表格填写、接口设计、性能改进、单元测试、异常处理几个方面展开。
一、PSP 表格(预估)
| PSP 阶段 | 预估耗时 (h) |
|---|---|
| Planning(计划) | 1.0 |
| Estimate(估时) | 0.3 |
| Development - Design(设计) | 1.5 |
| Development - Code(编码) | 3.0 |
| Code Review(代码审查) | 1.0 |
| Test - 写用例 | 2.0 |
| Test - 运行调试 | 1.5 |
| Postmortem(总结 + 博客) | 2.0 |
| 合计 | 12.3 |
二、计算模块接口的设计与实现过程
模块结构
按"一个模块一个职责"的思路把代码拆成 5 个文件,依赖单向,方便单测时按模块 mock:
3124004106/
├── main.py # 命令行入口、参数解析、端到端编排
├── fileio.py # 文件读写(UTF-8 优先、GBK 回退)
├── tokenizer.py # jieba 分词 + 标点/空白过滤
├── simhash.py # SimHash 指纹、Hamming 距离、余弦相似度
└── exceptions.py # PlagiarismError 异常层级
依赖图:main → fileio, tokenizer, simhash → exceptions。后面三个模块互相独立,每个都能单独构造测试用例,不会被其他模块的状态干扰。
关键函数
fileio.py
read_text(path)— UTF-8 strict 失败回退 GBK;空文件抛EmptyFileError;OSError包装为FileReadErrorwrite_answer(path, rate)— 写f"{rate:.2f}"
tokenizer.py
tokenize(text)—jieba.lcut切词,过滤标点(UnicodeP*类 + CJK 标点区段),拉丁字母小写化_is_punctuation(token)— 判断 token 是否全是标点/空白
simhash.py
compute_simhash(tokens, bits=64)— 词频加权,逐位累加 ± 权重,按符号位定指纹hamming_distance(a, b, bits=64)—bin((a ^ b) & mask).count("1")simhash_rate(a, b, bits=64)—1 - hamming / bitscosine_rate(tokens_a, tokens_b)— TF 向量余弦,作为对照算法
main.py
parse_args(argv)— 校验--method和 3 个路径参数main(argv)— 编排:读 → 分词 → 相似度 → 写;捕获PlagiarismError输出到 stderr 并返回 1
算法关键
默认路线:SimHash + Hamming 距离
注:算法的选用和设计有借助AI进行资料的查阅。
主流程:原文 → jieba分词 → 去标点 → SimHash(64位指纹) → Hamming → 归一化重复率
- 对每个 token 用 md5 取前 8 字节作 64 位稳定哈希。这里没用 Python 内置
hash(),因为PYTHONHASHSEED默认开启随机化,同一输入在不同进程里指纹会不一致,不利于复现。 - 用
Counter统计词频作为权重,逐位累加:该位为 1 则+weight,为 0 则-weight。 - 最终每位按符号定值:
>0 → 1,≤0 → 0,得到 64 位指纹。 - 重复率
rate = 1 - hamming_distance / 64。
对照路线:TF 余弦相似度(--method cosine 触发)
构建词频向量,计算两向量夹角余弦。这条路线主要用来和 SimHash 做对比,下面会展开。
三、计算模块接口部分的性能改进
性能分析
用 cProfile 跑了一份约 1 万字的合成文本(脚本见 profile_simhash.py),单次查重总耗时约 1.4 秒,远低于作业 5 秒的预算。

消耗最大的函数
| 排名 | 函数 | 累计耗时 | 说明 |
|---|---|---|---|
| 1 | jieba.initialize |
~0.77s | 首次加载默认词典(marshal.load) |
| 2 | jieba.get_DAG |
~0.85s | 构建有向无环图 |
| 3 | jieba.cut |
~1.15s | 分词主循环 |
| 4 | tokenizer.tokenize |
~1.35s | 含上述 jieba 调用 |
| 5 | _is_punctuation |
~0.14s | 标点过滤 |
注:累计耗时是含被调用子函数的总时间,所以 tokenizer.tokenize 比 jieba.cut 还高是正常的——它把 jieba 的开销都算进去了。
改进思路
瓶颈定位:约 99% 的时间花在 jieba 分词,SimHash 本身(compute_simhash + hamming_distance)几乎可以忽略。
已经做的优化:
- 用
Counter一次性统计词频,避免重复扫描 tokens 列表 - 逐位累加用 list 而非 numpy,避免大数组对象创建开销
- md5 只取前 8 字节而不是全部 16 字节,减少后续位运算量
没做但可以考虑的方向:
- jieba 词典加载约 0.6s 是固定开销,单次运行场景下没法优化;若改成批量查重服务,可以复用进程把这部分摊掉
四、计算模块部分单元测试展示
测试用例统计
在AI帮助下,写了 75 个测试用例,分支覆盖率 97%:
| 模块 | 用例数 | 覆盖率 |
|---|---|---|
exceptions.py |
6 | 100% |
fileio.py |
12 | 88% |
main.py |
20 | 93% |
simhash.py |
22 | 97% |
tokenizer.py |
15 | 96% |
| 总计 | 75 | 97% |
典型用例
test_simhash.py — SimHash 对同义替换的鲁棒性
def test_simhash_rate_assignment_sample_in_range():
"""作业样例:星期天 vs 周天,重复率应在 [0.5, 0.95] 区间。"""
from tokenizer import tokenize
t1 = tokenize("今天是星期天,天气晴,今天晚上我要去看电影。")
t2 = tokenize("今天是周天,天气晴朗,我晚上要去看电影。")
rate = simhash_rate(compute_simhash(t1), compute_simhash(t2))
assert 0.5 <= rate <= 0.95
构造思路:直接拿作业样例做集成测试,验证在同义词替换(星期天→周天)和局部增删(去掉"今天")的情况下,SimHash 仍能给出符合常识的重复率区间。
test_fileio.py — 编码回退
def test_read_text_gbk_fallback(tmp_path):
"""UTF-8 strict 失败后应回退 GBK。"""
p = tmp_path / "g.txt"
p.write_text("今天星期天", encoding="gbk")
assert read_text(str(p)) == "今天星期天"
构造思路:中文 Windows 环境下使用 GBK 进行编码的文件很常见,必须能正确读取。用 tmp_path fixture 创建临时文件,测试结束自动清理,不污染工作区。
test_main.py — 纯标点特判
def test_main_only_punctuation_returns_zero(tmp_path):
"""纯标点输入 → tokens 为空 → 特判 0.00。"""
ans = tmp_path / "ans.txt"
rc = main([
str(FIXTURES / "only_punct.txt"),
str(FIXTURES / "orig.txt"),
str(ans),
])
assert rc == 0
assert ans.read_text(encoding="utf-8") == "0.00"
构造思路:纯标点输入会让 tokens 为空,compute_simhash 返回 0,两边都为 0 时 simhash_rate(0, 0) = 1.0 反而会被误判为完全重复。这是一个必须特判的边界情况。
覆盖率截图

五、计算模块部分异常处理说明
所有异常都继承自 PlagiarismError,main 函数统一捕获后把错误信息输出到 stderr 并返回退出码 1,保证不会把异常堆栈或乱码写进答案文件。
1. InvalidArgumentError
场景:命令行参数数量错误,或 --method 值非法。
def test_parse_args_unknown_method_raises():
with pytest.raises(InvalidArgumentError) as exc:
parse_args(["--method", "levenshtein", "a.txt", "b.txt", "c.txt"])
assert "未知算法" in str(exc.value)
2. FileReadError
场景:原文/抄袭版文件不存在、无权限、或编码既非 UTF-8 也非 GBK。
def test_read_text_non_utf8_non_gbk_raises(tmp_path):
p = tmp_path / "bin.txt"
p.write_bytes(b"\xff\xfe\x00\x01\x02\xff")
with pytest.raises(FileReadError) as exc:
read_text(str(p))
assert "编码" in str(exc.value)
3. EmptyFileError
场景:文件存在但内容为空(或仅空白),无任何字符可查重。
def test_read_text_empty_file_raises(tmp_path):
p = tmp_path / "empty.txt"
p.write_text("", encoding="utf-8")
with pytest.raises(EmptyFileError):
read_text(str(p))
4. FileWriteError
场景:答案文件目录不存在或不可写。
def test_write_answer_to_nonexistent_dir_raises(tmp_path):
bad = tmp_path / "no_such_dir" / "ans.txt"
with pytest.raises(FileWriteError):
write_answer(str(bad), 0.5)
六、PSP 表格(实际)
| PSP 阶段 | 预估 (h) | 实际 (h) |
|---|---|---|
| Planning(计划) | 1.0 | 1.2 |
| Estimate(估时) | 0.3 | 0.3 |
| Development - Design(设计) | 1.5 | 1.8 |
| Development - Code(编码) | 3.0 | 3.5 |
| Code Review(代码审查) | 1.0 | 1.2 |
| Test - 写用例 | 2.0 | 2.4 |
| Test - 运行调试 | 1.5 | 1.6 |
| Postmortem(总结 + 博客) | 2.0 | 2.3 |
| 合计 | 12.3 | 14.3 |
七、参考资料
算法参考文献
- Manku G S, Jain A, Das Sarma A. Detecting Near-Duplicates for Web Crawling. WWW 2007.
- 论文链接:https://www2007.wwwconference.org/papers/paper141.pdf
- 中文版blog解读:https://blog.csdn.net/xielin420625/article/details/78460765 (Google 网页查重 SimHash 算法详解)
- 阮一峰. TF-IDF 与余弦相似性的应用(二):找出相似文章. 2013.
作业要求遵守的开发规范
- 阮一峰. Commit message 和 Change log 编写指南. 2016.
- 邹欣. 现代软件工程讲义 2 工程师的能力评估和发展
- 邹欣. 现代软件工程讲义 2 开发技术 — 单元测试 & 回归测试
浙公网安备 33010602011771号