第一次个人编程作业——论文查重
GitHub 仓库:https://github.com/RicharTan-zlz/software-engineering-homework-Sec/tree/main/3124004068
学号:3124004068
一、PSP 表格
1.1 预估耗时
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | |
| · Analysis | · 需求分析(包括学习新技术) | 30 |
| · Design Spec | · 生成设计文档 | 20 |
| · Design Review | · 设计复审 | 15 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 30 |
| · Coding | · 具体编码 | 120 |
| · Code Review | · 代码复审 | 20 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 60 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 20 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结,提出过程改进计划 | 20 |
| 合计 | 385 |
1.2 实际耗时
| PSP2.1 | Personal Software Process Stages | 实际耗时(分钟) |
|---|---|---|
| Planning | 计划 | |
| · Estimate | · 估计这个任务需要多少时间 | 25 |
| Development | 开发 | |
| · Analysis | · 需求分析 | 35 |
| · Design Spec | · 生成设计文档 | 25 |
| · Design Review | · 设计复审 | 15 |
| · Coding Standard | · 代码规范 | 10 |
| · Design | · 具体设计 | 40 |
| · Coding | · 具体编码 | 150 |
| · Code Review | · 代码复审 | 25 |
| · Test | · 测试 | 70 |
| Reporting | 报告 | |
| · Test Report | · 测试报告 | 25 |
| · Size Measurement | · 计算工作量 | 10 |
| · Postmortem & Process Improvement Plan | · 事后总结 | 20 |
| 合计 | 450 |
分析:实际耗时 450 分钟 > 预估 385 分钟,主要超支在编码(+30)与测试(+10)。原因是首次使用 jieba 分词库,需要熟悉 API 和调试。后续应预留更多 buffer。
二、计算模块接口的设计与实现过程
2.1 需求分析
输入:3 个命令行参数——原文文件路径、抄袭版论文文件路径、答案文件路径。
输出:答案文件内写入一个浮点数,精确到小数点后两位,表示重复率。
关键约束:
- 5 秒内必须给出答案
- 内存不超过 2048MB
- 不能联网、不能读写其他文件
- 支持 UTF-8 / GBK 编码的中文文本
2.2 算法选择
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 编辑距离 | 直观 | O(n²) 太慢 | ✗ |
| 余弦相似度 | 准确,速度快 | 对语序不敏感 | ✓ |
| SimHash | 极快,适合海量文本 | 精度略低 | ✓ |
| 最长公共子串 | 适合短文本 | 长文本性能差 | ✗ |
| SimHash + 余弦融合 | 精度+速度兼顾 | 实现略复杂 | ✓ 采用 |
最终方案:相似度 = 0.5 × SimHash相似度 + 0.5 × 余弦相似度
2.3 模块设计
程序分为 5 层:
┌─────────────────────────────────┐
│ main() 命令行入口 │
├─────────────────────────────────┤
│ read_file / write_result IO层 │
├─────────────────────────────────┤
│ preprocess 预处理层 │
├─────────────────────────────────┤
│ simhash / cosine 算法层 │
├─────────────────────────────────┤
│ FileError 异常层 │
└─────────────────────────────────┘
函数清单:
| 函数 | 功能 |
|---|---|
read_file(path) |
读取文件,UTF-8/GBK 兼容 |
write_result(path, rate) |
写出结果,保留两位小数 |
preprocess(text) |
清洗+分词 |
_hash64(token) |
单 token 的 64 位哈希 |
compute_simhash(tokens) |
计算 SimHash 指纹 |
hamming_distance(h1, h2) |
汉明距离 |
cosine_similarity(t1, t2) |
余弦相似度 |
calculate_similarity(orig, copy) |
综合相似度 |
main() |
命令行入口 |
2.4 关键流程图
calculate_similarity 流程:
[开始]
│
▼
读取两个文本
│
▼
┌───────────────┐
│ preprocess │ 清洗+分词
└───────┬───────┘
│
┌─────┴─────┐
▼ ▼
SimHash 词频向量
│ │
▼ ▼
汉明距离 余弦相似度
│ │
└─────┬─────┘
▼
加权融合(0.5/0.5)
│
▼
输出rate
│
▼
[结束]
2.5 算法的独到之处
- 混合策略:SimHash 对整体近似判断快,余弦对细节精确,两者互补,减少漏判误判。
- 中文友好:使用 jieba 分词 +
HMM=False关闭隐马尔可夫模型,避免把"今天天气"当作一个词,切分更细。 - 零网络依赖:jieba 词典本地加载,不联网,符合安全约束。
- 异常鲁棒:文件编码 UTF-8/GBK 双兼容,文件不存在、参数错误均有明确提示。
2.6 核心代码
def calculate_similarity(orig_text: str, copy_text: str) -> float:
"""
综合相似度:
0.5 × SimHash 相似度 + 0.5 × 余弦相似度
"""
tokens1 = preprocess(orig_text)
tokens2 = preprocess(copy_text)
# 边界处理
if not tokens1 and not tokens2:
return 1.0
if not tokens1 or not tokens2:
return 0.0
# SimHash 相似度
h1 = compute_simhash(tokens1)
h2 = compute_simhash(tokens2)
dist = hamming_distance(h1, h2)
simhash_sim = 1.0 - dist / 64.0
# 余弦相似度
cos_sim = cosine_similarity(tokens1, tokens2)
# 加权融合
final = 0.5 * simhash_sim + 0.5 * cos_sim
return max(0.0, min(1.0, final))
三、计算模块接口部分的性能改进
3.1 性能分析工具
使用 cProfile + snakeviz 生成性能分析图。
命令:
python -m cProfile -o profile_big.out main.py big_orig.txt big_copy.txt ans.txt
python -m snakeviz profile_big.out
3.2 性能分析图
测试条件:315 KB 中文文本,用时 1.023 秒。

分析结果:
总耗时 1.02s
│
├─ main.py:161(main) 0.586s (57%)
│ │
│ └─ main.py:132(calculate_similarity) 0.569s
│ │
│ └─ main.py:53(preprocess) 0.561s (55%) ← 瓶颈
│ │
│ └─ jieba.cut 0.490s (48%)
│ └─ __cut_DAG_NO_HMM 0.466s
│
├─ jieba.initialize() 0.353s (34%) 一次性
│ └─ marshal.load 0.349s
│
└─ 其他(导入等) 0.084s
3.3 消耗最大的函数
| 函数 | 耗时占比 | 说明 |
|---|---|---|
jieba.cut |
48% | 分词是最大瓶颈 |
jieba.initialize |
34% | 词典加载,一次性 |
preprocess |
55%(含子调用) | 预处理整体 |
calculate_similarity |
0.569s | 算法核心 |
| 算法本身(SimHash+余弦) | < 0.01s | 可忽略 |
3.4 改进思路
| 优化点 | 优化前 | 优化后 | 效果 |
|---|---|---|---|
| jieba 预加载 | 每次调用初始化 | 模块级 jieba.initialize() |
避免重复加载 |
| 关闭 HMM | cut(text) |
cut(text, HMM=False) |
提速约 15% |
| 词频缓存 | 遍历两次 | Counter 一次 | -5% |
优化效果:
| 文本大小 | 优化前 | 优化后 |
|---|---|---|
| 315 KB | 1.3s | 1.02s |
| 10 MB | 3.5s | 2.1s |
结论:满足 5s 限制,且远低于内存 2048MB 上限。
四、计算模块部分单元测试展示
4.1 测试策略
- 白盒:覆盖所有分支(空文本、相同文本、完全不同、无公共词)
- 黑盒:等价类划分(正常、边界、异常)
- 共设计 27 个测试用例
4.2 测试用例清单
| 编号 | 测试函数 | 构造思路 | 预期 |
|---|---|---|---|
| 1 | test_preprocess_empty | 空字符串 | 返回 [] |
| 2 | test_preprocess_punctuation | 含标点 | 标点被去除 |
| 3 | test_preprocess_case | 大小写 | 统一小写 |
| 4 | test_simhash_identical | 同文本 | 哈希相同 |
| 5 | test_hamming_same | 同一哈希 | 距离 0 |
| 6 | test_hamming_symmetry | 两个哈希 | 对称 |
| 7 | test_cosine_identical | 同词集 | ≈1.0 |
| 8 | test_cosine_no_common | 无公共词 | 0.0 |
| 9 | test_cosine_empty | 空输入 | 0.0 |
| 10 | test_similarity_identical | 完全相同 | >0.99 |
| 11 | test_similarity_sample | 样例 | 0.5~1.0 |
| 12 | test_similarity_completely_different | 完全不同 | <0.5 |
| 13 | test_read_missing_file | 不存在文件 | FileError |
| 14 | test_write_and_read | 写读一致 | "0.88" |
| 15 | test_read_empty_string_path | 空路径 | FileError |
| 16 | test_write_invalid_path | 无效路径 | FileError |
| 17 | test_read_gbk_file | GBK 文件 | 自动回退 |
| 18 | test_hamming_zero | 0 距离 | 0 |
| 19 | test_cosine_one_empty | 一边为空 | 0.0 |
| 20 | test_similarity_both_empty | 两边为空 | 1.0 |
| 21 | test_similarity_one_empty | 一边为空 | 0.0 |
| 22 | test_main_wrong_args | 参数错误 | 退出码 1 |
| 23 | test_main_file_not_exist | 文件不存在 | 退出码 2 |
| 24 | test_main_normal_run | 正常调用 | 退出码 0 |
| 25 | test_main_with_valid_files | 直接调 main | 写出文件 |
| 26 | test_preprocess_with_whitespace | 多空格 | 正确分词 |
| 27 | test_read_directory_as_file | 传目录 | FileError |
4.3 测试代码片段
class TestSimilarity(unittest.TestCase):
"""综合相似度测试"""
def test_similarity_identical(self):
"""测试10: 完全相同文本相似度接近 1"""
text = "今天是星期天,天气晴,今天晚上我要去看电影。"
rate = calculate_similarity(text, text)
self.assertGreater(rate, 0.99)
def test_similarity_sample(self):
"""测试11: 样例抄袭文本相似度在 0.5~1.0 之间"""
orig = "今天是星期天,天气晴,今天晚上我要去看电影。"
copy = "今天是周天,天气晴朗,我晚上要去看电影。"
rate = calculate_similarity(orig, copy)
self.assertGreater(rate, 0.5)
self.assertLess(rate, 1.0)
class TestFileIO(unittest.TestCase):
"""文件读写测试"""
def test_read_missing_file(self):
"""测试13: 读取不存在的文件抛出 FileError"""
with self.assertRaises(FileError):
read_file("nonexistent_file_xyz_12345.txt")
def test_read_gbk_file(self):
"""测试17: GBK 编码文件自动回退读取"""
with tempfile.NamedTemporaryFile(
mode='wb', suffix='.txt', delete=False) as f:
f.write("中文测试内容".encode('gbk'))
path = f.name
try:
content = read_file(path)
self.assertIn("中文", content)
finally:
os.unlink(path)
4.4 测试结果

Ran 27 tests in 1.616s
OK
4.5 覆盖率报告

Name Stmts Miss Cover Missing
--------------------------------------------
main.py 110 13 88% 41-42, 88, 129, 164-165, 174-179, 183
test_main.py 138 1 99% 250
--------------------------------------------
TOTAL 248 14 94%
结论:
main.py覆盖率 88%,超过 85% 的目标- 总体覆盖率 94%
- 未覆盖部分主要是极端分支(IO 错误、磁盘满、兜底异常等),不影响核心功能
五、计算模块部分异常处理说明
5.1 异常设计总览
| 异常类型 | 设计目标 | 触发场景 | 处理方式 |
|---|---|---|---|
FileError |
统一文件异常 | 文件不存在/不是文件/不可读 | 打印错误,exit(2) |
UnicodeDecodeError |
编码兼容 | UTF-8 解码失败 | 回退 GBK 读取 |
IOError |
IO 错误 | 磁盘满、权限不足 | 包装为 FileError |
| 参数错误 | 提示用法 | argv ≠ 4 | 打印用法,exit(1) |
| 未预期异常 | 兜底 | 任意其他异常 | exit(3) |
5.2 每种异常的单元测试
异常1:文件不存在
def test_read_missing_file(self):
"""对应场景:用户在命令行传入了错误的路径"""
with self.assertRaises(FileError):
read_file("nonexistent_file_xyz.txt")
异常2:编码回退(GBK)
def test_read_gbk_file(self):
"""对应场景:输入文件是 GBK 编码(Windows 记事本默认)"""
with tempfile.NamedTemporaryFile(
mode='wb', suffix='.txt', delete=False) as f:
f.write("中文测试".encode('gbk'))
path = f.name
try:
content = read_file(path)
self.assertIn("中文", content)
finally:
os.unlink(path)
异常3:写文件失败
def test_write_invalid_path(self):
"""对应场景:输出路径指向不存在的目录"""
with self.assertRaises(FileError):
write_result("Z:/nonexistent_dir/ans.txt", 0.5)
异常4:空文本
def test_similarity_both_empty(self):
"""对应场景:输入文件为空"""
self.assertEqual(calculate_similarity("", ""), 1.0)
def test_similarity_one_empty(self):
self.assertEqual(calculate_similarity("", "abc"), 0.0)
self.assertEqual(calculate_similarity("abc", ""), 0.0)
异常5:参数错误
def test_main_wrong_args(self):
"""对应场景:命令行只传了 0 个参数"""
r = subprocess.run(
[sys.executable, "main.py"],
capture_output=True, text=True
)
self.assertEqual(r.returncode, 1)
5.3 异常测试覆盖
27 个单元测试中,有 8 个专门针对异常,覆盖文件、编码、IO、参数、空输入等关键场景,确保程序在任何情况下都不会崩溃或静默失败。
六、Git 提交记录
共 6 次有意义的 commit,符合"每完成一个功能就提交"的要求:
| # | commit | 内容 |
|---|---|---|
| 1 | 8a1f738 |
feat: 完成基本文件读取与输出功能 |
| 2 | c9b1366 |
feat: 加入 jieba 分词、SimHash 与余弦相似度综合算法 |
| 3 | 11b26d2 |
test: 添加单元测试,main.py 覆盖率 88%,总覆盖率 94% |
| 4 | 1f30acb |
style: 修复 pylint 警告,代码评分达到 10/10 |
| 5 | d7a3491 |
chore: 忽略性能分析文件 profile.out |
| 6 | d9b5a21 |
docs: 添加 README 项目说明文档 |
七、运行方式
# 安装依赖
pip install -r requirements.txt
# 运行
python main.py orig.txt orig_add.txt ans.txt
# 输出示例
重复率: 0.76
八、总结
本次作业从需求分析 → 算法设计 → 编码 → 测试 → 性能优化 → 文档,完整走了一遍个人软件开发流程。最大收获:
- 算法选择要看场景:SimHash + 余弦的融合,比单一算法既快又准。
- 性能瓶颈要靠工具定位:cProfile 明确指向 jieba,才能针对性优化。
- 异常处理是工程化关键:编码兼容、文件校验、参数检查一个不能少。
- Git 记录反映开发节奏:每次完成一个功能就 commit,方便回溯。
PSP 实际 vs 预估:总耗时 450 分钟 > 预估 385 分钟,主要超支在编码(+30)与测试(+10),说明初期对细节估计不足,后续应预留更多 buffer。
完
浙公网安备 33010602011771号