软工第二周作业
软工第二周作业——论文查重
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://www.cnblogs.com/dl-1-2/p/22946319 |
| 这个作业的目标 | 让我们熟悉软件开发的流程 |
GitHub 仓库地址:https://github.com/delang666/delang666
一、PSP2.1 表格
| PSP2.1 Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划 | ||
| · Estimate(估计任务需要多少时间) | 30 | 15 |
| Development 开发 | ||
| · Analysis(需求分析,包括学习新技术) | 60 | 45 |
| · Design Spec(生成设计文档) | 40 | 25 |
| · Design Review(设计复审) | 30 | 15 |
| · Coding Standard(代码规范) | 20 | 10 |
| · Design(具体设计) | 50 | 30 |
| · Coding(具体编码) | 120 | 90 |
| · Code Review(代码复审) | 40 | 25 |
| · Test(测试:自测、修改代码、提交修改) | 90 | 60 |
| Reporting 报告 | ||
| · Test Report(测试报告) | 40 | 20 |
| · Size Measurement(计算工作量) | 20 | 10 |
| · Postmortem & Process Improvement Plan(事后总结与改进计划) | 30 | 20 |
| 合计 | 570 | 365 |
二、计算模块接口的设计与实现
2.1 代码组织
程序按「单一职责」拆成三个模块:
| 文件 | 主要类 / 函数 | 职责 |
|---|---|---|
simhash.py |
SimHash |
核心算法:分词、词频统计、64 位指纹生成、汉明距离、相似度 |
file_handler.py |
read_text / write_text |
文件读写 + UTF-8/GBK 编码回退 + 异常定义 |
main.py |
parse_args / check_similarity / main |
命令行参数解析、编排流程、写出答案 |
模块关系(单向依赖,无循环):
main.py ──调用──> file_handler.py(读写文件)
└──────调用──> simhash.py(计算相似度)
2.2 关键流程
解析命令行参数(3个路径)
│
▼
read_text(原文) read_text(抄袭版)
│
▼
SimHash(原文).similarity( SimHash(抄袭版) )
│
▼
write_text(答案文件, "{:.2f}".format(相似度))
2.3 算法关键与独到之处
算法关键:SimHash 局部敏感哈希。
- 用
jieba对文本分词,过滤纯标点/空白 token。 - 对每个词计算确定性字符串哈希(不用 Python 内置
hash(),因为它带随机种子、结果不可复现)。 - 按位加权累加:某位为 1 加「词频」、为 0 减「词频」。
- 累加向量
>=0的位置记为 1,得到 64 位指纹。 - 用汉明距离换算相似度:
1 - 汉明距离/64。
独到之处:
- 词频合并优化:先统计词频,相同词只做一次 64 位加权、权重为词频。把「逐词遍历」降为「逐唯一词遍历」,位运算次数从
64 × token 总数降到64 × 唯一词数。 - 确定性哈希:自定义
_string_hash,跨进程、跨平台结果一致,保证同一文件多次运行答案稳定。 - 编码自适应:优先 UTF-8,失败自动回退 GBK,兼容 Windows 中文环境(样例
orig.txt即 UTF-8,运行时无需关心)。 - 对增删改天然鲁棒:SimHash 基于词集合的加权统计,局部增删改只影响少量位,汉明距离变化小,相似度结果稳定。
样例验证(test_data/ 提供):
| 样例对 | 原文 | 抄袭方式 | 输出重复率 |
|---|---|---|---|
orig.txt vs orig_add.txt |
软件工程… | 末尾追加 AI/云原生段落 | 0.97 |
orig.txt vs orig_del.txt |
软件工程… | 删除后半段 | 0.89 |
orig.txt vs orig_dis_1.txt |
软件工程… | 改 1 处(学科→科学) | 0.97 |
orig.txt vs orig_dis_10.txt |
软件工程… | 改 10 处 | 0.94 |
orig.txt vs orig_dis_15.txt |
软件工程… | 改 15 处 | 0.88 |
三、计算模块接口部分的性能改进
改进耗时:约 40 分钟。
改进思路:
- 初版
_compute对每个 token(含重复词)都执行一次 64 位逐位累加,长文 + 大量重复词时位运算次数 =64 × token 总数。 - 改为先
dict统计词频,再对每个「唯一词」加权累加,位运算次数降为64 × 唯一词数。 - 分词结果用
jieba.cut(惰性生成器)边切边过滤,避免构造完整中间列表。
性能分析图(使用 cProfile 剖析 check_similarity,按自身耗时 tottime 排序):

上图为 python -m cProfile 在样例数据(orig.txt vs orig_add.txt)上跑出的耗时柱状图,可由项目内置脚本生成:
python profiling.py test_data/orig.txt test_data/orig_add.txt profile_output.png
消耗最大的函数:marshal.load(jieba 词典的一次性反序列化加载,~0.73s,占总耗时约 98%);其次是 jieba.get_DAG 与 jieba.calc(实际分词逻辑),而我编写的 SimHash._compute 自身耗时仅 0.003s,几乎可忽略。
四、计算模块部分单元测试展示
测试框架:Python 内置 unittest + coverage(分支覆盖率插件)。
测试用例设计思路(白盒为主,辅以边界值、等价类划分):
| 类别 | 测试点 | 用例数 |
|---|---|---|
| 正常路径 | 相同文本、高相似、改 1/10/15 处、增、删、主题完全不同 | 7 |
| 边界值 | 单字符、空文本、纯标点、空白 token | 4 |
| 算法性质 | 自反性、汉明距离对称性 | 2 |
| 文件 IO 异常 | UTF-8/GBK 编码、空文件、文件不存在、读写回环、读写 OSError | 8 |
| 主流程分支 | 参数错误退出码 1、文件缺失退出码 2、空文件写 0.00、成功写相似度、未知异常退出码 4、端到端 | 6 |
| 合计 | 25 |
部分测试代码(覆盖算法核心 + 异常分支):
def test_similar_text_high_similarity(self):
"""仅少量改写的文本相似度应较高(> 0.6)。"""
a = SimHash(BASE_TEXT)
b = SimHash(SIMILAR_TEXT)
self.assertGreater(a.similarity(b), 0.6)
def test_hamming_distance_symmetric(self):
"""汉明距离具有对称性。"""
a = SimHash(BASE_TEXT)
b = SimHash(SIMILAR_TEXT)
self.assertEqual(a.hamming_distance(b), b.hamming_distance(a))
def test_read_gbk_fallback_oserror(self):
"""UTF-8 解码失败且 GBK 回退也失败时应抛 FileReadError。"""
path = _write_temp()
def fake_open(*_args, **kwargs):
if kwargs.get("encoding") == "utf-8":
raise UnicodeDecodeError("utf-8", b"", 0, 1, "invalid")
raise OSError("permission denied")
try:
with mock.patch("builtins.open", side_effect=fake_open):
with self.assertRaises(FileReadError):
read_text(path)
finally:
os.remove(path)
运行与覆盖率:
# 运行 25 个单元测试
python -m unittest test_main -v
# 生成分支覆盖率报告(终端 + HTML)
coverage run --branch -m unittest test_main
coverage report -m
coverage html # 产物在 htmlcov/index.html
实际跑出的覆盖率(项目源码,不含测试文件):
Name Stmts Miss Branch BrPart Cover
---------------------------------------------------
file_handler.py 23 0 2 0 100%
main.py 37 2 8 2 91%
simhash.py 46 1 20 1 97%
---------------------------------------------------
TOTAL 106 3 30 3 96%
未覆盖行均为标准防御代码:默认参数分支(main.py:71 argv is None)、if __name__ == "__main__" 入口(main.py:97)、空字符串哈希兜底(simhash.py:54)。

五、计算模块部分异常处理说明
| 异常 | 设计目标 | 触发场景 | 对应单元测试 |
|---|---|---|---|
ArgumentError(main.py 自定义) |
参数个数非法时尽早失败并给出用法提示,避免索引越界崩溃 | 命令行参数不足/多余 3 个 | test_wrong_arg_count_exits_1 |
FileNotFoundError(内置) |
输入文件不存在时给出明确错误码退出,不写垃圾答案 | 路径错误/文件被删 | test_missing_file_exits_2 / test_read_not_exists |
FileReadError(file_handler 自定义) |
区分「读不到/解码失败/写不进」这类文件级错误 | 编码失败、OSError、目录不存在 | test_read_oserror_raises_file_read_error / test_read_gbk_fallback_oserror / test_write_error_raises_file_read_error |
EmptyFileError(file_handler 自定义) |
空文件无法比对,main 捕获后降级输出 0.00,保证评测不崩溃 |
输入文件内容为空 | test_empty_file_writes_zero / test_empty_file_error |
兜底 Exception(main.py) |
任何未预期的运行时异常都以退出码 4 优雅退出 | 第三方库异常、磁盘满等 | test_unexpected_error_exits_4 |
以 EmptyFileError 为例的单元测试(空文件场景 → 答案文件写 0.00 不崩溃):
def test_main_empty_file_writes_zero(self):
"""空文件场景:main 捕获 EmptyFileError,降级输出 0.00,进程不崩溃。"""
empty = _write_temp() # 空文件
ans = os.path.join(tempfile.gettempdir(), "ans_direct_empty.txt")
try:
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
main([empty, empty, ans])
self.assertEqual(read_text(ans).strip(), "0.00")
finally:
os.remove(empty)
if os.path.exists(ans):
os.remove(ans)
下表把每种异常对应的测试代码片段与实测运行结果(... ok 表示通过)一并列出,全部 8 个异常相关测试实测一次跑通:
5.1 ArgumentError —— 参数个数非法
def test_wrong_arg_count_exits_1(self):
with self.assertRaises(SystemExit) as cm:
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
main(["a.txt"]) # 只给 1 个参数
self.assertEqual(cm.exception.code, 1)
实测:test_wrong_arg_count_exits_1 (test_main.TestMainFunction) ... ok
5.2 FileNotFoundError —— 输入文件不存在
def test_missing_file_exits_2(self):
with self.assertRaises(SystemExit) as cm:
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
main(["no_such.txt", "no_such2.txt", "out.txt"])
self.assertEqual(cm.exception.code, 2)
实测:test_missing_file_exits_2 (test_main.TestMainFunction) ... ok
5.3 FileReadError —— 文件读不到 / 编码失败 / 写不进
file_handler.py 自定义异常,封装三类文件级错误。下例展示 OSError 被翻译为 FileReadError:
def test_read_oserror_raises_file_read_error(self):
"""文件存在但打开失败(OSError)时应抛 FileReadError。"""
path = _write_temp()
try:
with mock.patch(
"builtins.open", side_effect=OSError("permission denied")
):
with self.assertRaises(FileReadError):
read_text(path)
finally:
os.remove(path)
实测 3 个相关用例:
test_read_oserror_raises_file_read_error ... ok
test_read_gbk_fallback_oserror ... ok
test_write_error_raises_file_read_error ... ok
5.4 EmptyFileError —— 空文件 → 降级输出 0.00
file_handler.py 自定义异常;main() 捕获后不崩溃,写 0.00 到答案文件。
实测:
test_empty_file_writes_zero (test_main.TestMainFunction) ... ok
test_empty_file_error (test_main.TestFileHandler) ... ok
5.5 兜底 Exception —— 未知异常退出码 4
def test_unexpected_error_exits_4(self):
with mock.patch("main.read_text", side_effect=RuntimeError("boom")):
with self.assertRaises(SystemExit) as cm:
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
main(["a.txt", "b.txt", "c.txt"])
self.assertEqual(cm.exception.code, 4)
实测:test_unexpected_error_exits_4 (test_main.TestMainFunction) ... ok
5.6 8 个异常测试一次跑完的输出
$ python -m unittest test_main.TestMainFunction.test_wrong_arg_count_exits_1 \
test_main.TestMainFunction.test_missing_file_exits_2 \
test_main.TestMainFunction.test_empty_file_writes_zero \
test_main.TestMainFunction.test_unexpected_error_exits_4 \
test_main.TestFileHandler.test_read_oserror_raises_file_read_error \
test_main.TestFileHandler.test_empty_file_error \
test_main.TestFileHandler.test_read_gbk_fallback_oserror \
test_main.TestFileHandler.test_write_error_raises_file_read_error -v
test_wrong_arg_count_exits_1 (test_main.TestMainFunction) ... ok
test_missing_file_exits_2 (test_main.TestMainFunction) ... ok
test_empty_file_writes_zero (test_main.TestMainFunction) ... ok
test_unexpected_error_exits_4 (test_main.TestMainFunction) ... ok
test_read_oserror_raises_file_read_error (test_main.TestFileHandler)
文件存在但打开失败(OSError)时应抛 FileReadError。 ... ok
test_empty_file_error (test_main.TestFileHandler) ... ok
test_read_gbk_fallback_oserror (test_main.TestFileHandler)
UTF-8 解码失败且 GBK 回退也失败时应抛 FileReadError。 ... ok
test_write_error_raises_file_read_error (test_main.TestFileHandler)
写入不存在的目录时应抛 FileReadError。 ... ok
----------------------------------------------------------------------
Ran 8 tests in 0.012s
OK


浙公网安备 33010602011771号